From bdea739c5516778037b11cd1dc5b5d3c1a0ad5ed Mon Sep 17 00:00:00 2001 From: mantikoros Date: Fri, 29 Jul 2022 09:20:21 -0700 Subject: [PATCH 01/83] multiple choice contract card --- web/components/contract/contract-card.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/web/components/contract/contract-card.tsx b/web/components/contract/contract-card.tsx index 164f3f27..e418178c 100644 --- a/web/components/contract/contract-card.tsx +++ b/web/components/contract/contract-card.tsx @@ -115,7 +115,8 @@ export function ContractCard(props: { {question}

- {outcomeType === 'FREE_RESPONSE' && + {(outcomeType === 'FREE_RESPONSE' || + outcomeType === 'MULTIPLE_CHOICE') && (resolution ? ( )} - {outcomeType === 'FREE_RESPONSE' && ( + {(outcomeType === 'FREE_RESPONSE' || + outcomeType === 'MULTIPLE_CHOICE') && ( Date: Fri, 29 Jul 2022 15:09:48 -0700 Subject: [PATCH 02/83] manalink referrals --- web/pages/link/[slug].tsx | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/web/pages/link/[slug].tsx b/web/pages/link/[slug].tsx index 119fec77..fa728c85 100644 --- a/web/pages/link/[slug].tsx +++ b/web/pages/link/[slug].tsx @@ -1,14 +1,17 @@ import { useRouter } from 'next/router' -import { useState } from 'react' +import { useEffect, useState } from 'react' import { SEO } from 'web/components/SEO' import { Title } from 'web/components/title' import { claimManalink } from 'web/lib/firebase/api' import { useManalink } from 'web/lib/firebase/manalinks' import { ManalinkCard } from 'web/components/manalink-card' import { useUser } from 'web/hooks/use-user' -import { firebaseLogin } from 'web/lib/firebase/users' +import { firebaseLogin, getUser } from 'web/lib/firebase/users' import { Row } from 'web/components/layout/row' import { Button } from 'web/components/button' +import { useSaveReferral } from 'web/hooks/use-save-referral' +import { User } from 'common/lib/user' +import { Manalink } from 'common/manalink' export default function ClaimPage() { const user = useUser() @@ -18,6 +21,8 @@ export default function ClaimPage() { const [claiming, setClaiming] = useState(false) const [error, setError] = useState(undefined) + useReferral(user, manalink) + if (!manalink) { return <> } @@ -76,3 +81,13 @@ export default function ClaimPage() { ) } + +const useReferral = (user: User | undefined | null, manalink?: Manalink) => { + const [creator, setCreator] = useState(undefined) + + useEffect(() => { + if (manalink?.fromId) getUser(manalink.fromId).then(setCreator) + }, [manalink]) + + useSaveReferral(user, { defaultReferrer: creator?.username }) +} From 5812d8ed2ebde8557f8e09e1b9c038cfabdd3222 Mon Sep 17 00:00:00 2001 From: mantikoros Date: Fri, 29 Jul 2022 16:02:18 -0700 Subject: [PATCH 03/83] manalink qr code --- web/components/manalink-card.tsx | 18 +++++++++++++++++- .../manalinks/create-links-button.tsx | 11 +++++++---- web/components/qr-code.tsx | 16 ++++++++++++++++ 3 files changed, 40 insertions(+), 5 deletions(-) create mode 100644 web/components/qr-code.tsx diff --git a/web/components/manalink-card.tsx b/web/components/manalink-card.tsx index 51880f5d..c8529609 100644 --- a/web/components/manalink-card.tsx +++ b/web/components/manalink-card.tsx @@ -10,6 +10,7 @@ import { DotsHorizontalIcon } from '@heroicons/react/solid' import { contractDetailsButtonClassName } from './contract/contract-info-dialog' import { useUserById } from 'web/hooks/use-user' import getManalinkUrl from 'web/get-manalink-url' +import { QrcodeIcon } from '@heroicons/react/outline' export type ManalinkInfo = { expiresTime: number | null maxUses: number | null @@ -78,7 +79,9 @@ export function ManalinkCardFromView(props: { const { className, link, highlightedSlug } = props const { message, amount, expiresTime, maxUses, claims } = link const [showDetails, setShowDetails] = useState(false) - + const qrUrl = `https://api.qrserver.com/v1/create-qr-code/?size=${200}x${200}&data=${getManalinkUrl( + link.slug + )}` return ( {formatMoney(amount)} + + + {!finishedCreating && ( @@ -199,17 +202,17 @@ function CreateManalinkForm(props: { copyPressed ? 'bg-indigo-50 text-indigo-500 transition-none' : '' )} > -
- {getManalinkUrl(highlightedSlug)} -
+
{url}
{ - navigator.clipboard.writeText(getManalinkUrl(highlightedSlug)) + navigator.clipboard.writeText(url) setCopyPressed(true) }} className="my-auto ml-2 h-5 w-5 cursor-pointer transition hover:opacity-50" /> + + )} diff --git a/web/components/qr-code.tsx b/web/components/qr-code.tsx new file mode 100644 index 00000000..a10f8886 --- /dev/null +++ b/web/components/qr-code.tsx @@ -0,0 +1,16 @@ +export function QRCode(props: { + url: string + className?: string + width?: number + height?: number +}) { + const { url, className, width, height } = { + width: 200, + height: 200, + ...props, + } + + const qrUrl = `https://api.qrserver.com/v1/create-qr-code/?size=${width}x${height}&data=${url}` + + return +} From 079a2a3936426edc70c1319dd0ddeddc79261ea6 Mon Sep 17 00:00:00 2001 From: mantikoros Date: Fri, 29 Jul 2022 16:06:22 -0700 Subject: [PATCH 04/83] eslint --- web/pages/link/[slug].tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/pages/link/[slug].tsx b/web/pages/link/[slug].tsx index fa728c85..af3f01a8 100644 --- a/web/pages/link/[slug].tsx +++ b/web/pages/link/[slug].tsx @@ -10,7 +10,7 @@ import { firebaseLogin, getUser } from 'web/lib/firebase/users' import { Row } from 'web/components/layout/row' import { Button } from 'web/components/button' import { useSaveReferral } from 'web/hooks/use-save-referral' -import { User } from 'common/lib/user' +import { User } from 'common/user' import { Manalink } from 'common/manalink' export default function ClaimPage() { From be01a152305a769c5b2859d1e2ca01c25e872524 Mon Sep 17 00:00:00 2001 From: James Grugett Date: Fri, 29 Jul 2022 19:08:51 -0500 Subject: [PATCH 05/83] Refactor search to not use Algolia components (#705) * In progress refactor to not use Algolia components * Cleanup * Only query when necessary * Read and update url params for query and sort * Don't push router * Don't update url if using default sort * Add popstate listener to improve home navigation * Remove console.logs --- web/components/contract-search.tsx | 366 +++++++++++------------- web/hooks/use-sort-and-query-params.tsx | 64 +++-- web/pages/home.tsx | 10 +- 3 files changed, 213 insertions(+), 227 deletions(-) diff --git a/web/components/contract-search.tsx b/web/components/contract-search.tsx index c7660138..8596aa2e 100644 --- a/web/components/contract-search.tsx +++ b/web/components/contract-search.tsx @@ -1,26 +1,14 @@ /* eslint-disable react-hooks/exhaustive-deps */ import algoliasearch from 'algoliasearch/lite' -import { - Configure, - InstantSearch, - SearchBox, - SortBy, - useInfiniteHits, - useSortBy, -} from 'react-instantsearch-hooks-web' import { Contract } from 'common/contract' -import { - Sort, - useInitialQueryAndSort, - useUpdateQueryAndSort, -} from '../hooks/use-sort-and-query-params' +import { Sort, useQueryAndSortParams } from '../hooks/use-sort-and-query-params' import { ContractHighlightOptions, ContractsGrid, } from './contract/contracts-list' import { Row } from './layout/row' -import { useEffect, useMemo, useRef, useState } from 'react' +import { useEffect, useMemo, useState } from 'react' import { Spacer } from './layout/spacer' import { ENV, IS_PRIVATE_MANIFOLD } from 'common/envs/constants' import { useUser } from 'web/hooks/use-user' @@ -30,8 +18,9 @@ import ContractSearchFirestore from 'web/pages/contract-search-firestore' import { useMemberGroups } from 'web/hooks/use-group' import { Group, NEW_USER_GROUP_SLUGS } from 'common/group' import { PillButton } from './buttons/pill-button' -import { sortBy } from 'lodash' +import { range, sortBy } from 'lodash' import { DEFAULT_CATEGORY_GROUPS } from 'common/categories' +import { Col } from './layout/col' const searchClient = algoliasearch( 'GJQPAYENIF', @@ -40,16 +29,15 @@ const searchClient = algoliasearch( const indexPrefix = ENV === 'DEV' ? 'dev-' : '' -const sortIndexes = [ - { label: 'Newest', value: indexPrefix + 'contracts-newest' }, - // { label: 'Oldest', value: indexPrefix + 'contracts-oldest' }, - { label: 'Most popular', value: indexPrefix + 'contracts-score' }, - { label: 'Most traded', value: indexPrefix + 'contracts-most-traded' }, - { label: '24h volume', value: indexPrefix + 'contracts-24-hour-vol' }, - { label: 'Last updated', value: indexPrefix + 'contracts-last-updated' }, - { label: 'Subsidy', value: indexPrefix + 'contracts-liquidity' }, - { label: 'Close date', value: indexPrefix + 'contracts-close-date' }, - { label: 'Resolve date', value: indexPrefix + 'contracts-resolve-date' }, +const sortOptions = [ + { label: 'Newest', value: 'newest' }, + { label: 'Most popular', value: 'score' }, + { label: 'Most traded', value: 'most-traded' }, + { label: '24h volume', value: '24-hour-vol' }, + { label: 'Last updated', value: 'last-updated' }, + { label: 'Subsidy', value: 'liquidity' }, + { label: 'Close date', value: 'close-date' }, + { label: 'Resolve date', value: 'resolve-date' }, ] export const DEFAULT_SORT = 'score' @@ -108,13 +96,12 @@ export function ContractSearch(props: { memberPillGroups.length > 0 ? memberPillGroups : defaultPillGroups const follows = useFollows(user?.id) - const { initialSort } = useInitialQueryAndSort(querySortOptions) - const sort = sortIndexes - .map(({ value }) => value) - .includes(`${indexPrefix}contracts-${initialSort ?? ''}`) - ? initialSort - : querySortOptions?.defaultSort ?? DEFAULT_SORT + const { shouldLoadFromStorage, defaultSort } = querySortOptions ?? {} + const { query, setQuery, sort, setSort } = useQueryAndSortParams({ + defaultSort, + shouldLoadFromStorage, + }) const [filter, setFilter] = useState( querySortOptions?.defaultFilter ?? 'open' @@ -123,62 +110,129 @@ export function ContractSearch(props: { const [pillFilter, setPillFilter] = useState(undefined) - const selectFilter = (pill: string | undefined) => () => { + const selectPill = (pill: string | undefined) => () => { setPillFilter(pill) + setPage(0) track('select search category', { category: pill ?? 'all' }) } - const { filters, numericFilters } = useMemo(() => { - let filters = [ - filter === 'open' ? 'isResolved:false' : '', - filter === 'closed' ? 'isResolved:false' : '', - filter === 'resolved' ? 'isResolved:true' : '', - additionalFilter?.creatorId - ? `creatorId:${additionalFilter.creatorId}` - : '', - additionalFilter?.tag ? `lowercaseTags:${additionalFilter.tag}` : '', - additionalFilter?.groupSlug - ? `groupLinks.slug:${additionalFilter.groupSlug}` - : '', - pillFilter && pillFilter !== 'personal' && pillFilter !== 'your-bets' - ? `groupLinks.slug:${pillFilter}` - : '', - pillFilter === 'personal' - ? // Show contracts in groups that the user is a member of - memberGroupSlugs - .map((slug) => `groupLinks.slug:${slug}`) - // Show contracts created by users the user follows - .concat(follows?.map((followId) => `creatorId:${followId}`) ?? []) - // Show contracts bet on by users the user follows - .concat( - follows?.map((followId) => `uniqueBettorIds:${followId}`) ?? [] - ) - : '', - // Subtract contracts you bet on from For you. - pillFilter === 'personal' && user ? `uniqueBettorIds:-${user.id}` : '', - pillFilter === 'your-bets' && user - ? // Show contracts bet on by the user - `uniqueBettorIds:${user.id}` - : '', - ].filter((f) => f) - // Hack to make Algolia work. - filters = ['', ...filters] + let facetFilters = [ + filter === 'open' ? 'isResolved:false' : '', + filter === 'closed' ? 'isResolved:false' : '', + filter === 'resolved' ? 'isResolved:true' : '', + additionalFilter?.creatorId + ? `creatorId:${additionalFilter.creatorId}` + : '', + additionalFilter?.tag ? `lowercaseTags:${additionalFilter.tag}` : '', + additionalFilter?.groupSlug + ? `groupLinks.slug:${additionalFilter.groupSlug}` + : '', + pillFilter && pillFilter !== 'personal' && pillFilter !== 'your-bets' + ? `groupLinks.slug:${pillFilter}` + : '', + pillFilter === 'personal' + ? // Show contracts in groups that the user is a member of + memberGroupSlugs + .map((slug) => `groupLinks.slug:${slug}`) + // Show contracts created by users the user follows + .concat(follows?.map((followId) => `creatorId:${followId}`) ?? []) + // Show contracts bet on by users the user follows + .concat( + follows?.map((followId) => `uniqueBettorIds:${followId}`) ?? [] + ) + : '', + // Subtract contracts you bet on from For you. + pillFilter === 'personal' && user ? `uniqueBettorIds:-${user.id}` : '', + pillFilter === 'your-bets' && user + ? // Show contracts bet on by the user + `uniqueBettorIds:${user.id}` + : '', + ].filter((f) => f) + // Hack to make Algolia work. + facetFilters = ['', ...facetFilters] - const numericFilters = [ - filter === 'open' ? `closeTime > ${Date.now()}` : '', - filter === 'closed' ? `closeTime <= ${Date.now()}` : '', - ].filter((f) => f) - - return { filters, numericFilters } - }, [ - filter, - Object.values(additionalFilter ?? {}).join(','), - memberGroupSlugs.join(','), - (follows ?? []).join(','), - pillFilter, - ]) + const numericFilters = [ + filter === 'open' ? `closeTime > ${Date.now()}` : '', + filter === 'closed' ? `closeTime <= ${Date.now()}` : '', + ].filter((f) => f) const indexName = `${indexPrefix}contracts-${sort}` + const index = useMemo(() => searchClient.initIndex(indexName), [indexName]) + + const [page, setPage] = useState(0) + const [numPages, setNumPages] = useState(1) + const [hitsByPage, setHitsByPage] = useState<{ [page: string]: Contract[] }>( + {} + ) + + useEffect(() => { + let wasMostRecentQuery = true + index + .search(query, { + facetFilters, + numericFilters, + page, + hitsPerPage: 20, + }) + .then((results) => { + if (!wasMostRecentQuery) return + + if (page === 0) { + setHitsByPage({ + [0]: results.hits as any as Contract[], + }) + } else { + setHitsByPage((hitsByPage) => ({ + ...hitsByPage, + [page]: results.hits, + })) + } + setNumPages(results.nbPages) + }) + return () => { + wasMostRecentQuery = false + } + // Note numeric filters are unique based on current time, so can't compare + // them by value. + }, [query, page, index, JSON.stringify(facetFilters), filter]) + + const loadMore = () => { + if (page >= numPages - 1) return + + const haveLoadedCurrentPage = hitsByPage[page] + if (haveLoadedCurrentPage) setPage(page + 1) + } + + const hits = range(0, page + 1) + .map((p) => hitsByPage[p] ?? []) + .flat() + + const contracts = hits.filter( + (c) => !additionalFilter?.excludeContractIds?.includes(c.id) + ) + + const showTime = + sort === 'close-date' || sort === 'resolve-date' ? sort : undefined + + const updateQuery = (newQuery: string) => { + setQuery(newQuery) + setPage(0) + } + + const selectFilter = (newFilter: filter) => { + if (newFilter === filter) return + setFilter(newFilter) + setPage(0) + trackCallback('select search filter', { filter: newFilter }) + } + + const selectSort = (newSort: Sort) => { + if (newSort === sort) return + + setPage(0) + setSort(newSort) + track('select sort', { sort: newSort }) + } if (IS_PRIVATE_MANIFOLD || process.env.NEXT_PUBLIC_FIREBASE_EMULATE) { return ( @@ -190,23 +244,19 @@ export function ContractSearch(props: { } return ( - + - @@ -237,14 +285,14 @@ export function ContractSearch(props: { All {user ? 'For you' : 'Featured'} @@ -253,7 +301,7 @@ export function ContractSearch(props: { Your bets @@ -264,7 +312,7 @@ export function ContractSearch(props: { {name} @@ -280,103 +328,17 @@ export function ContractSearch(props: { memberGroupSlugs.length === 0 ? ( <>You're not following anyone, nor in any of your own groups yet. ) : ( - )} - - ) -} - -export function ContractSearchInner(props: { - querySortOptions?: { - defaultSort: Sort - shouldLoadFromStorage?: boolean - } - onContractClick?: (contract: Contract) => void - overrideGridClassName?: string - hideQuickBet?: boolean - excludeContractIds?: string[] - highlightOptions?: ContractHighlightOptions - cardHideOptions?: { - hideQuickBet?: boolean - hideGroupLink?: boolean - } -}) { - const { - querySortOptions, - onContractClick, - overrideGridClassName, - cardHideOptions, - excludeContractIds, - highlightOptions, - } = props - const { initialQuery } = useInitialQueryAndSort(querySortOptions) - - const { query, setQuery, setSort } = useUpdateQueryAndSort({ - shouldLoadFromStorage: true, - }) - - useEffect(() => { - setQuery(initialQuery) - }, [initialQuery]) - - const { currentRefinement: index } = useSortBy({ - items: [], - }) - - useEffect(() => { - setQuery(query) - }, [query]) - - const isFirstRender = useRef(true) - useEffect(() => { - if (isFirstRender.current) { - isFirstRender.current = false - return - } - - const sort = index.split('contracts-')[1] as Sort - if (sort) { - setSort(sort) - } - }, [index]) - - const [isInitialLoad, setIsInitialLoad] = useState(true) - useEffect(() => { - const id = setTimeout(() => setIsInitialLoad(false), 1000) - return () => clearTimeout(id) - }, []) - - const { showMore, hits, isLastPage } = useInfiniteHits() - let contracts = hits as any as Contract[] - - if (isInitialLoad && contracts.length === 0) return <> - - const showTime = index.endsWith('close-date') - ? 'close-date' - : index.endsWith('resolve-date') - ? 'resolve-date' - : undefined - - if (excludeContractIds) - contracts = contracts.filter((c) => !excludeContractIds.includes(c.id)) - - return ( - + ) } diff --git a/web/hooks/use-sort-and-query-params.tsx b/web/hooks/use-sort-and-query-params.tsx index 9023dc1a..fb5bf29b 100644 --- a/web/hooks/use-sort-and-query-params.tsx +++ b/web/hooks/use-sort-and-query-params.tsx @@ -1,8 +1,6 @@ import { defaults, debounce } from 'lodash' import { useRouter } from 'next/router' import { useEffect, useMemo, useState } from 'react' -import { useSearchBox } from 'react-instantsearch-hooks-web' -import { track } from 'web/lib/service/analytics' import { DEFAULT_SORT } from 'web/components/contract-search' const MARKETS_SORT = 'markets_sort' @@ -74,51 +72,69 @@ export function useInitialQueryAndSort(options?: { } } -export function useUpdateQueryAndSort(props: { - shouldLoadFromStorage: boolean +export function useQueryAndSortParams(options?: { + defaultSort?: Sort + shouldLoadFromStorage?: boolean }) { - const { shouldLoadFromStorage } = props + const { defaultSort = DEFAULT_SORT, shouldLoadFromStorage = true } = + options ?? {} const router = useRouter() + const { s: sort, q: query } = router.query as { + q?: string + s?: Sort + } + const setSort = (sort: Sort | undefined) => { - if (sort !== router.query.s) { - router.query.s = sort - router.replace({ query: { ...router.query, s: sort } }, undefined, { - shallow: true, - }) - if (shouldLoadFromStorage) { - localStorage.setItem(MARKETS_SORT, sort || '') - } + router.replace({ query: { ...router.query, s: sort } }, undefined, { + shallow: true, + }) + if (shouldLoadFromStorage) { + localStorage.setItem(MARKETS_SORT, sort || '') } } - const { query, refine } = useSearchBox() + const [queryState, setQueryState] = useState(query) + + useEffect(() => { + setQueryState(query) + }, [query]) // Debounce router query update. const pushQuery = useMemo( () => debounce((query: string | undefined) => { - if (query) { - router.query.q = query - } else { - delete router.query.q - } - router.replace({ query: router.query }, undefined, { + router.replace({ query: { ...router.query, q: query } }, undefined, { shallow: true, }) - track('search', { query }) - }, 500), + }, 100), [router] ) const setQuery = (query: string | undefined) => { - refine(query ?? '') + setQueryState(query) pushQuery(query) } + useEffect(() => { + // If there's no sort option, then set the one from localstorage + if (router.isReady && !sort && shouldLoadFromStorage) { + const localSort = localStorage.getItem(MARKETS_SORT) as Sort + if (localSort && localSort !== defaultSort) { + // Use replace to not break navigating back. + router.replace( + { query: { ...router.query, s: localSort } }, + undefined, + { shallow: true } + ) + } + } + }) + return { + sort: sort ?? defaultSort, + query: queryState ?? '', setSort, setQuery, - query, } } diff --git a/web/pages/home.tsx b/web/pages/home.tsx index 61003895..ab915ae3 100644 --- a/web/pages/home.tsx +++ b/web/pages/home.tsx @@ -81,11 +81,18 @@ const useContractPage = () => { if (!username || !contractSlug) setContract(undefined) else { // Show contract if route is to a contract: '/[username]/[contractSlug]'. - getContractFromSlug(contractSlug).then(setContract) + getContractFromSlug(contractSlug).then((contract) => { + const path = location.pathname.split('/').slice(1) + const [_username, contractSlug] = path + // Make sure we're still on the same contract. + if (contract?.slug === contractSlug) setContract(contract) + }) } } } + addEventListener('popstate', updateContract) + const { pushState, replaceState } = window.history window.history.pushState = function () { @@ -101,6 +108,7 @@ const useContractPage = () => { } return () => { + removeEventListener('popstate', updateContract) window.history.pushState = pushState window.history.replaceState = replaceState } From d6cf4332da7a58dbd10c3d571711b2c779e27aa1 Mon Sep 17 00:00:00 2001 From: James Grugett Date: Fri, 29 Jul 2022 17:37:34 -0700 Subject: [PATCH 06/83] Delete query param when empty --- web/hooks/use-sort-and-query-params.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/web/hooks/use-sort-and-query-params.tsx b/web/hooks/use-sort-and-query-params.tsx index fb5bf29b..ae226e87 100644 --- a/web/hooks/use-sort-and-query-params.tsx +++ b/web/hooks/use-sort-and-query-params.tsx @@ -104,7 +104,9 @@ export function useQueryAndSortParams(options?: { const pushQuery = useMemo( () => debounce((query: string | undefined) => { - router.replace({ query: { ...router.query, q: query } }, undefined, { + const queryObj = { ...router.query, q: query || undefined } + if (!query) delete queryObj.q + router.replace({ query: queryObj }, undefined, { shallow: true, }) }, 100), From 003301762c884f3e1abca501cfac49873f556ac5 Mon Sep 17 00:00:00 2001 From: James Grugett Date: Fri, 29 Jul 2022 17:37:53 -0700 Subject: [PATCH 07/83] Ignore filter on contract status when searching --- web/components/contract-search.tsx | 98 ++++++++++++++++-------------- 1 file changed, 52 insertions(+), 46 deletions(-) diff --git a/web/components/contract-search.tsx b/web/components/contract-search.tsx index 8596aa2e..4202618f 100644 --- a/web/components/contract-search.tsx +++ b/web/components/contract-search.tsx @@ -116,45 +116,49 @@ export function ContractSearch(props: { track('select search category', { category: pill ?? 'all' }) } - let facetFilters = [ - filter === 'open' ? 'isResolved:false' : '', - filter === 'closed' ? 'isResolved:false' : '', - filter === 'resolved' ? 'isResolved:true' : '', - additionalFilter?.creatorId - ? `creatorId:${additionalFilter.creatorId}` - : '', - additionalFilter?.tag ? `lowercaseTags:${additionalFilter.tag}` : '', - additionalFilter?.groupSlug - ? `groupLinks.slug:${additionalFilter.groupSlug}` - : '', - pillFilter && pillFilter !== 'personal' && pillFilter !== 'your-bets' - ? `groupLinks.slug:${pillFilter}` - : '', - pillFilter === 'personal' - ? // Show contracts in groups that the user is a member of - memberGroupSlugs - .map((slug) => `groupLinks.slug:${slug}`) - // Show contracts created by users the user follows - .concat(follows?.map((followId) => `creatorId:${followId}`) ?? []) - // Show contracts bet on by users the user follows - .concat( - follows?.map((followId) => `uniqueBettorIds:${followId}`) ?? [] - ) - : '', - // Subtract contracts you bet on from For you. - pillFilter === 'personal' && user ? `uniqueBettorIds:-${user.id}` : '', - pillFilter === 'your-bets' && user - ? // Show contracts bet on by the user - `uniqueBettorIds:${user.id}` - : '', - ].filter((f) => f) + let facetFilters = query + ? [] + : [ + filter === 'open' ? 'isResolved:false' : '', + filter === 'closed' ? 'isResolved:false' : '', + filter === 'resolved' ? 'isResolved:true' : '', + additionalFilter?.creatorId + ? `creatorId:${additionalFilter.creatorId}` + : '', + additionalFilter?.tag ? `lowercaseTags:${additionalFilter.tag}` : '', + additionalFilter?.groupSlug + ? `groupLinks.slug:${additionalFilter.groupSlug}` + : '', + pillFilter && pillFilter !== 'personal' && pillFilter !== 'your-bets' + ? `groupLinks.slug:${pillFilter}` + : '', + pillFilter === 'personal' + ? // Show contracts in groups that the user is a member of + memberGroupSlugs + .map((slug) => `groupLinks.slug:${slug}`) + // Show contracts created by users the user follows + .concat(follows?.map((followId) => `creatorId:${followId}`) ?? []) + // Show contracts bet on by users the user follows + .concat( + follows?.map((followId) => `uniqueBettorIds:${followId}`) ?? [] + ) + : '', + // Subtract contracts you bet on from For you. + pillFilter === 'personal' && user ? `uniqueBettorIds:-${user.id}` : '', + pillFilter === 'your-bets' && user + ? // Show contracts bet on by the user + `uniqueBettorIds:${user.id}` + : '', + ].filter((f) => f) // Hack to make Algolia work. facetFilters = ['', ...facetFilters] - const numericFilters = [ - filter === 'open' ? `closeTime > ${Date.now()}` : '', - filter === 'closed' ? `closeTime <= ${Date.now()}` : '', - ].filter((f) => f) + const numericFilters = query + ? [] + : [ + filter === 'open' ? `closeTime > ${Date.now()}` : '', + filter === 'closed' ? `closeTime <= ${Date.now()}` : '', + ].filter((f) => f) const indexName = `${indexPrefix}contracts-${sort}` const index = useMemo(() => searchClient.initIndex(indexName), [indexName]) @@ -253,16 +257,18 @@ export function ContractSearch(props: { placeholder={showPlaceHolder ? `Search ${filter} markets` : ''} className="input input-bordered w-full" /> - + {!query && ( + + )} {!hideOrderSelector && ( )} - {!hideOrderSelector && ( + {!hideOrderSelector && !query && ( + setChallengeInfo((m: challengeInfo) => { + return { + ...m, + amount: parseInt(e.target.value), + acceptorAmount: editingAcceptorAmount + ? m.acceptorAmount + : parseInt(e.target.value), + } + }) + } + /> + + + on + {challengeInfo.outcome === 'YES' ? : } + + + + + If they bet: + +
+ {editingAcceptorAmount ? ( + +
+ + M$ + + + setChallengeInfo((m: challengeInfo) => { + return { + ...m, + acceptorAmount: parseInt(e.target.value), + } + }) + } + /> +
+ + ) : ( + + {formatMoney(challengeInfo.acceptorAmount)} + + )} +
+ on + {challengeInfo.outcome === 'YES' ? : } +
+ + + {!editingAcceptorAmount && ( + + )} + + + {error} + + )} + {finishedCreating && ( + <> + + + <div>Share the challenge using the link.</div> + <button + onClick={() => { + copyToClipboard(challengeSlug) + toast('Link copied to clipboard!') + }} + className={'btn btn-outline mb-4 whitespace-nowrap normal-case'} + > + <LinkIcon className={'mr-2 h-5 w-5'} /> + Copy link + </button> + + <QRCode url={challengeSlug} className="self-center" /> + <Row className={'gap-1 text-gray-500'}> + See your other + <SiteLink className={'underline'} href={'/challenges'}> + challenges + </SiteLink> + </Row> + </> + )} + </> + ) +} diff --git a/web/components/contract/contract-card-preview.tsx b/web/components/contract/contract-card-preview.tsx new file mode 100644 index 00000000..06a7f7f6 --- /dev/null +++ b/web/components/contract/contract-card-preview.tsx @@ -0,0 +1,36 @@ +import { Contract } from 'common/contract' +import { getBinaryProbPercent } from 'web/lib/firebase/contracts' +import { richTextToString } from 'common/util/parse' +import { contractTextDetails } from 'web/components/contract/contract-details' + +export const getOpenGraphProps = (contract: Contract) => { + const { + resolution, + question, + creatorName, + creatorUsername, + outcomeType, + creatorAvatarUrl, + description: desc, + } = contract + const probPercent = + outcomeType === 'BINARY' ? getBinaryProbPercent(contract) : undefined + + const stringDesc = typeof desc === 'string' ? desc : richTextToString(desc) + + const description = resolution + ? `Resolved ${resolution}. ${stringDesc}` + : probPercent + ? `${probPercent} chance. ${stringDesc}` + : stringDesc + + return { + question, + probability: probPercent, + metadata: contractTextDetails(contract), + creatorName, + creatorUsername, + creatorAvatarUrl, + description, + } +} diff --git a/web/components/contract/contract-overview.tsx b/web/components/contract/contract-overview.tsx index 50c5a7e6..28eabb04 100644 --- a/web/components/contract/contract-overview.tsx +++ b/web/components/contract/contract-overview.tsx @@ -1,4 +1,4 @@ -import { tradingAllowed } from 'web/lib/firebase/contracts' +import { contractUrl, tradingAllowed } from 'web/lib/firebase/contracts' import { Col } from '../layout/col' import { Spacer } from '../layout/spacer' import { ContractProbGraph } from './contract-prob-graph' @@ -8,8 +8,8 @@ import { Linkify } from '../linkify' import clsx from 'clsx' import { - FreeResponseResolutionOrChance, BinaryResolutionOrChance, + FreeResponseResolutionOrChance, NumericResolutionOrExpectation, PseudoNumericResolutionOrExpectation, } from './contract-card' @@ -19,8 +19,13 @@ import { AnswersGraph } from '../answers/answers-graph' import { Contract, CPMMBinaryContract } from 'common/contract' import { ContractDescription } from './contract-description' import { ContractDetails } from './contract-details' -import { ShareMarket } from '../share-market' import { NumericGraph } from './numeric-graph' +import { CreateChallengeButton } from 'web/components/challenges/create-challenge-button' +import React from 'react' +import { copyToClipboard } from 'web/lib/util/copy' +import toast from 'react-hot-toast' +import { LinkIcon } from '@heroicons/react/outline' +import { CHALLENGES_ENABLED } from 'common/challenge' export const ContractOverview = (props: { contract: Contract @@ -32,8 +37,10 @@ export const ContractOverview = (props: { const user = useUser() const isCreator = user?.id === creatorId + const isBinary = outcomeType === 'BINARY' const isPseudoNumeric = outcomeType === 'PSEUDO_NUMERIC' + const showChallenge = user && isBinary && !resolution && CHALLENGES_ENABLED return ( <Col className={clsx('mb-6', className)}> @@ -116,13 +123,47 @@ export const ContractOverview = (props: { <AnswersGraph contract={contract} bets={bets} /> )} {outcomeType === 'NUMERIC' && <NumericGraph contract={contract} />} - {(contract.description || isCreator) && <Spacer h={6} />} - {isCreator && <ShareMarket className="px-2" contract={contract} />} + {/* {(contract.description || isCreator) && <Spacer h={6} />} */} <ContractDescription className="px-2" contract={contract} isCreator={isCreator} /> + {/*<Row className="mx-4 mt-4 hidden justify-around sm:block">*/} + {/* {showChallenge && (*/} + {/* <Col className="gap-3">*/} + {/* <div className="text-lg">⚔️ Challenge a friend ⚔️</div>*/} + {/* <CreateChallengeButton user={user} contract={contract} />*/} + {/* </Col>*/} + {/* )}*/} + {/* {isCreator && (*/} + {/* <Col className="gap-3">*/} + {/* <div className="text-lg">Share your market</div>*/} + {/* <ShareMarketButton contract={contract} />*/} + {/* </Col>*/} + {/* )}*/} + {/*</Row>*/} + <Row className="mx-4 mt-6 block justify-around"> + {showChallenge && ( + <Col className="gap-3"> + <CreateChallengeButton user={user} contract={contract} /> + </Col> + )} + {isCreator && ( + <Col className="gap-3"> + <button + onClick={() => { + copyToClipboard(contractUrl(contract)) + toast('Link copied to clipboard!') + }} + className={'btn btn-outline mb-4 whitespace-nowrap normal-case'} + > + <LinkIcon className={'mr-2 h-5 w-5'} /> + Share market + </button> + </Col> + )} + </Row> </Col> ) } diff --git a/web/components/copy-link-button.tsx b/web/components/copy-link-button.tsx index 4ce4140d..f3489f3d 100644 --- a/web/components/copy-link-button.tsx +++ b/web/components/copy-link-button.tsx @@ -2,7 +2,6 @@ import React, { Fragment } from 'react' import { LinkIcon } from '@heroicons/react/outline' import { Menu, Transition } from '@headlessui/react' import clsx from 'clsx' - import { copyToClipboard } from 'web/lib/util/copy' import { ToastClipboard } from 'web/components/toast-clipboard' import { track } from 'web/lib/service/analytics' @@ -14,6 +13,8 @@ export function CopyLinkButton(props: { tracking?: string buttonClassName?: string toastClassName?: string + icon?: React.ComponentType<{ className?: string }> + label?: string }) { const { url, displayUrl, tracking, buttonClassName, toastClassName } = props diff --git a/web/components/feed/contract-activity.tsx b/web/components/feed/contract-activity.tsx index b1c8f6ee..cd490701 100644 --- a/web/components/feed/contract-activity.tsx +++ b/web/components/feed/contract-activity.tsx @@ -26,7 +26,10 @@ export function ContractActivity(props: { const contract = useContractWithPreload(props.contract) ?? props.contract const comments = props.comments - const updatedBets = useBets(contract.id) + const updatedBets = useBets(contract.id, { + filterChallenges: false, + filterRedemptions: true, + }) const bets = (updatedBets ?? props.bets).filter( (bet) => !bet.isRedemption && bet.amount !== 0 ) diff --git a/web/components/feed/feed-bets.tsx b/web/components/feed/feed-bets.tsx index 408404ba..29645136 100644 --- a/web/components/feed/feed-bets.tsx +++ b/web/components/feed/feed-bets.tsx @@ -10,11 +10,14 @@ import { UsersIcon } from '@heroicons/react/solid' import { formatMoney, formatPercent } from 'common/util/format' import { OutcomeLabel } from 'web/components/outcome-label' import { RelativeTimestamp } from 'web/components/relative-timestamp' -import React, { Fragment } from 'react' +import React, { Fragment, useEffect } from 'react' import { uniqBy, partition, sumBy, groupBy } from 'lodash' import { JoinSpans } from 'web/components/join-spans' import { UserLink } from '../user-page' import { formatNumericProbability } from 'common/pseudo-numeric' +import { SiteLink } from 'web/components/site-link' +import { getChallenge, getChallengeUrl } from 'web/lib/firebase/challenges' +import { Challenge } from 'common/challenge' export function FeedBet(props: { contract: Contract @@ -79,7 +82,15 @@ export function BetStatusText(props: { const { outcomeType } = contract const isPseudoNumeric = outcomeType === 'PSEUDO_NUMERIC' const isFreeResponse = outcomeType === 'FREE_RESPONSE' - const { amount, outcome, createdTime } = bet + const { amount, outcome, createdTime, challengeSlug } = bet + const [challenge, setChallenge] = React.useState<Challenge>() + useEffect(() => { + if (challengeSlug) { + getChallenge(challengeSlug, contract.id).then((c) => { + setChallenge(c) + }) + } + }, [challengeSlug, contract.id]) const bought = amount >= 0 ? 'bought' : 'sold' const outOfTotalAmount = @@ -133,6 +144,14 @@ export function BetStatusText(props: { {fromProb === toProb ? `at ${fromProb}` : `from ${fromProb} to ${toProb}`} + {challengeSlug && ( + <SiteLink + href={challenge ? getChallengeUrl(challenge) : ''} + className={'mx-1'} + > + [challenge] + </SiteLink> + )} </> )} <RelativeTimestamp time={createdTime} /> diff --git a/web/components/nav/sidebar.tsx b/web/components/nav/sidebar.tsx index a051faed..713bc575 100644 --- a/web/components/nav/sidebar.tsx +++ b/web/components/nav/sidebar.tsx @@ -29,6 +29,7 @@ import { Spacer } from '../layout/spacer' 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' const logout = async () => { // log out, and then reload the page, in case SSR wants to boot them out @@ -60,26 +61,50 @@ function getMoreNavigation(user?: User | null) { } if (!user) { - return [ - { 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' }, - ] + 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 [ + { 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' }, + ] } - return [ - { 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, - }, - ] + 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 [ + { 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, + }, + ] } const signedOutNavigation = [ @@ -119,6 +144,14 @@ 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' }, diff --git a/web/components/portfolio/portfolio-value-section.tsx b/web/components/portfolio/portfolio-value-section.tsx index fa50365b..611a19d1 100644 --- a/web/components/portfolio/portfolio-value-section.tsx +++ b/web/components/portfolio/portfolio-value-section.tsx @@ -10,8 +10,9 @@ import { PortfolioValueGraph } from './portfolio-value-graph' export const PortfolioValueSection = memo( function PortfolioValueSection(props: { portfolioHistory: PortfolioMetrics[] + disableSelector?: boolean }) { - const { portfolioHistory } = props + const { portfolioHistory, disableSelector } = props const lastPortfolioMetrics = last(portfolioHistory) const [portfolioPeriod, setPortfolioPeriod] = useState<Period>('allTime') @@ -30,7 +31,9 @@ export const PortfolioValueSection = memo( <div> <Row className="gap-8"> <div className="mb-4 w-full"> - <Col> + <Col + className={disableSelector ? 'items-center justify-center' : ''} + > <div className="text-sm text-gray-500">Portfolio value</div> <div className="text-lg"> {formatMoney( @@ -40,16 +43,18 @@ export const PortfolioValueSection = memo( </div> </Col> </div> - <select - className="select select-bordered self-start" - onChange={(e) => { - setPortfolioPeriod(e.target.value as Period) - }} - > - <option value="allTime">{allTimeLabel}</option> - <option value="weekly">7 days</option> - <option value="daily">24 hours</option> - </select> + {!disableSelector && ( + <select + className="select select-bordered self-start" + onChange={(e) => { + setPortfolioPeriod(e.target.value as Period) + }} + > + <option value="allTime">{allTimeLabel}</option> + <option value="weekly">7 days</option> + <option value="daily">24 hours</option> + </select> + )} </Row> <PortfolioValueGraph portfolioHistory={portfolioHistory} diff --git a/web/components/share-market-button.tsx b/web/components/share-market-button.tsx new file mode 100644 index 00000000..ef7b688d --- /dev/null +++ b/web/components/share-market-button.tsx @@ -0,0 +1,18 @@ +import { ENV_CONFIG } from 'common/envs/constants' +import { Contract, contractPath, contractUrl } from 'web/lib/firebase/contracts' +import { CopyLinkButton } from './copy-link-button' + +export function ShareMarketButton(props: { contract: Contract }) { + const { contract } = props + + const url = `https://${ENV_CONFIG.domain}${contractPath(contract)}` + + return ( + <CopyLinkButton + url={url} + displayUrl={contractUrl(contract)} + buttonClassName="btn-md rounded-l-none" + toastClassName={'-left-28 mt-1'} + /> + ) +} diff --git a/web/components/share-market.tsx b/web/components/share-market.tsx deleted file mode 100644 index be943a34..00000000 --- a/web/components/share-market.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import clsx from 'clsx' - -import { ENV_CONFIG } from 'common/envs/constants' - -import { Contract, contractPath, contractUrl } from 'web/lib/firebase/contracts' -import { CopyLinkButton } from './copy-link-button' -import { Col } from './layout/col' -import { Row } from './layout/row' - -export function ShareMarket(props: { contract: Contract; className?: string }) { - const { contract, className } = props - - const url = `https://${ENV_CONFIG.domain}${contractPath(contract)}` - - return ( - <Col className={clsx(className, 'gap-3')}> - <div>Share your market</div> - <Row className="mb-6 items-center"> - <CopyLinkButton - url={url} - displayUrl={contractUrl(contract)} - buttonClassName="btn-md rounded-l-none" - toastClassName={'-left-28 mt-1'} - /> - </Row> - </Col> - ) -} diff --git a/web/components/sign-up-prompt.tsx b/web/components/sign-up-prompt.tsx index 0edce22c..8882ccfd 100644 --- a/web/components/sign-up-prompt.tsx +++ b/web/components/sign-up-prompt.tsx @@ -2,16 +2,20 @@ import React from 'react' import { useUser } from 'web/hooks/use-user' import { firebaseLogin } from 'web/lib/firebase/users' import { withTracking } from 'web/lib/service/analytics' +import { Button } from './button' -export function SignUpPrompt() { +export function SignUpPrompt(props: { label?: string; className?: string }) { + const { label, className } = props const user = useUser() return user === null ? ( - <button - className="btn flex-1 whitespace-nowrap border-none bg-gradient-to-r from-indigo-500 to-blue-500 px-10 text-lg font-medium normal-case hover:from-indigo-600 hover:to-blue-600" + <Button onClick={withTracking(firebaseLogin, 'sign up to bet')} + className={className} + size="lg" + color="gradient" > - Sign up to bet! - </button> + {label ?? 'Sign up to bet!'} + </Button> ) : null } diff --git a/web/hooks/use-bets.ts b/web/hooks/use-bets.ts index 68b296cd..38b73dd1 100644 --- a/web/hooks/use-bets.ts +++ b/web/hooks/use-bets.ts @@ -9,12 +9,26 @@ import { } from 'web/lib/firebase/bets' import { LimitBet } from 'common/bet' -export const useBets = (contractId: string) => { +export const useBets = ( + contractId: string, + options?: { filterChallenges: boolean; filterRedemptions: boolean } +) => { const [bets, setBets] = useState<Bet[] | undefined>() useEffect(() => { - if (contractId) return listenForBets(contractId, setBets) - }, [contractId]) + if (contractId) + return listenForBets(contractId, (bets) => { + if (options) + setBets( + bets.filter( + (bet) => + (options.filterChallenges ? !bet.challengeSlug : true) && + (options.filterRedemptions ? !bet.isRedemption : true) + ) + ) + else setBets(bets) + }) + }, [contractId, options]) return bets } diff --git a/web/hooks/use-save-referral.ts b/web/hooks/use-save-referral.ts index 7772f9d2..cc96ec72 100644 --- a/web/hooks/use-save-referral.ts +++ b/web/hooks/use-save-referral.ts @@ -6,7 +6,7 @@ import { User, writeReferralInfo } from 'web/lib/firebase/users' export const useSaveReferral = ( user?: User | null, options?: { - defaultReferrer?: string + defaultReferrerUsername?: string contractId?: string groupId?: string } @@ -18,7 +18,7 @@ export const useSaveReferral = ( referrer?: string } - const referrerOrDefault = referrer || options?.defaultReferrer + const referrerOrDefault = referrer || options?.defaultReferrerUsername if (!user && router.isReady && referrerOrDefault) { writeReferralInfo(referrerOrDefault, { diff --git a/web/hooks/use-user.ts b/web/hooks/use-user.ts index 4c492d6c..d84c7d03 100644 --- a/web/hooks/use-user.ts +++ b/web/hooks/use-user.ts @@ -2,7 +2,7 @@ import { useContext, useEffect, useState } from 'react' import { useFirestoreDocumentData } from '@react-query-firebase/firestore' import { QueryClient } from 'react-query' -import { doc, DocumentData } from 'firebase/firestore' +import { doc, DocumentData, where } from 'firebase/firestore' import { PrivateUser } from 'common/user' import { getUser, diff --git a/web/lib/firebase/api.ts b/web/lib/firebase/api.ts index 87d94dce..5f250ce7 100644 --- a/web/lib/firebase/api.ts +++ b/web/lib/firebase/api.ts @@ -81,6 +81,10 @@ export function createGroup(params: any) { return call(getFunctionUrl('creategroup'), 'POST', params) } +export function acceptChallenge(params: any) { + return call(getFunctionUrl('acceptchallenge'), 'POST', params) +} + export function getCurrentUser(params: any) { return call(getFunctionUrl('getcurrentuser'), 'GET', params) } diff --git a/web/lib/firebase/challenges.ts b/web/lib/firebase/challenges.ts new file mode 100644 index 00000000..d62d5aac --- /dev/null +++ b/web/lib/firebase/challenges.ts @@ -0,0 +1,150 @@ +import { + collectionGroup, + doc, + getDoc, + orderBy, + query, + setDoc, + where, +} from 'firebase/firestore' +import { Challenge } from 'common/challenge' +import { customAlphabet } from 'nanoid' +import { coll, listenForValue, listenForValues } from './utils' +import { useEffect, useState } from 'react' +import { User } from 'common/user' +import { db } from './init' +import { Contract } from 'common/contract' +import { ENV_CONFIG } from 'common/envs/constants' + +export const challenges = (contractId: string) => + coll<Challenge>(`contracts/${contractId}/challenges`) + +export function getChallengeUrl(challenge: Challenge) { + return `https://${ENV_CONFIG.domain}/challenges/${challenge.creatorUsername}/${challenge.contractSlug}/${challenge.slug}` +} +export async function createChallenge(data: { + creator: User + outcome: 'YES' | 'NO' | number + contract: Contract + creatorAmount: number + acceptorAmount: number + expiresTime: number | null + message: string +}) { + const { + creator, + creatorAmount, + expiresTime, + message, + contract, + outcome, + acceptorAmount, + } = data + + // At 100 IDs per hour, using this alphabet and 8 chars, there's a 1% chance of collision in 2 years + // See https://zelark.github.io/nano-id-cc/ + const nanoid = customAlphabet( + '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ', + 8 + ) + const slug = nanoid() + + if (creatorAmount <= 0 || isNaN(creatorAmount) || !isFinite(creatorAmount)) + return null + + const challenge: Challenge = { + slug, + creatorId: creator.id, + creatorUsername: creator.username, + creatorName: creator.name, + creatorAvatarUrl: creator.avatarUrl, + creatorAmount, + creatorOutcome: outcome.toString(), + creatorOutcomeProb: creatorAmount / (creatorAmount + acceptorAmount), + acceptorOutcome: outcome === 'YES' ? 'NO' : 'YES', + acceptorAmount, + contractSlug: contract.slug, + contractId: contract.id, + contractQuestion: contract.question, + contractCreatorUsername: contract.creatorUsername, + createdTime: Date.now(), + expiresTime, + maxUses: 1, + acceptedByUserIds: [], + acceptances: [], + isResolved: false, + message, + } + + await setDoc(doc(challenges(contract.id), slug), challenge) + return challenge +} + +// TODO: This required an index, make sure to also set up in prod +function listUserChallenges(fromId?: string) { + return query( + collectionGroup(db, 'challenges'), + where('creatorId', '==', fromId), + orderBy('createdTime', 'desc') + ) +} + +function listChallenges() { + return query(collectionGroup(db, 'challenges')) +} + +export const useAcceptedChallenges = () => { + const [links, setLinks] = useState<Challenge[]>([]) + + useEffect(() => { + listenForValues(listChallenges(), (challenges: Challenge[]) => { + setLinks( + challenges + .sort((a: Challenge, b: Challenge) => b.createdTime - a.createdTime) + .filter((challenge) => challenge.acceptedByUserIds.length > 0) + ) + }) + }, []) + + return links +} + +export function listenForChallenge( + slug: string, + contractId: string, + setLinks: (challenge: Challenge | null) => void +) { + return listenForValue<Challenge>(doc(challenges(contractId), slug), setLinks) +} + +export function useChallenge(slug: string, contractId: string | undefined) { + const [challenge, setChallenge] = useState<Challenge | null>() + useEffect(() => { + if (slug && contractId) { + listenForChallenge(slug, contractId, setChallenge) + } + }, [contractId, slug]) + return challenge +} + +export function listenForUserChallenges( + fromId: string | undefined, + setLinks: (links: Challenge[]) => void +) { + return listenForValues<Challenge>(listUserChallenges(fromId), setLinks) +} + +export const useUserChallenges = (fromId: string) => { + const [links, setLinks] = useState<Challenge[]>([]) + + useEffect(() => { + return listenForUserChallenges(fromId, setLinks) + }, [fromId]) + + return links +} + +export const getChallenge = async (slug: string, contractId: string) => { + const challenge = await getDoc(doc(challenges(contractId), slug)) + return challenge.data() as Challenge +} diff --git a/web/lib/firebase/contracts.ts b/web/lib/firebase/contracts.ts index 9e5de871..3a751c18 100644 --- a/web/lib/firebase/contracts.ts +++ b/web/lib/firebase/contracts.ts @@ -35,6 +35,13 @@ export function contractPath(contract: Contract) { return `/${contract.creatorUsername}/${contract.slug}` } +export function contractPathWithoutContract( + creatorUsername: string, + slug: string +) { + return `/${creatorUsername}/${slug}` +} + export function homeContractPath(contract: Contract) { return `/home?c=${contract.slug}` } diff --git a/web/pages/[username]/[contractSlug].tsx b/web/pages/[username]/[contractSlug].tsx index 58e7c2e8..0da6c994 100644 --- a/web/pages/[username]/[contractSlug].tsx +++ b/web/pages/[username]/[contractSlug].tsx @@ -1,18 +1,18 @@ -import React, { useEffect, useState } from 'react' +import React, { useEffect, useMemo, useState } from 'react' import { ArrowLeftIcon } from '@heroicons/react/outline' +import { groupBy, keyBy, mapValues, sortBy, sumBy } from 'lodash' import { useContractWithPreload } from 'web/hooks/use-contract' import { ContractOverview } from 'web/components/contract/contract-overview' import { BetPanel } from 'web/components/bet-panel' import { Col } from 'web/components/layout/col' -import { useUser } from 'web/hooks/use-user' +import { useUser, useUserById } from 'web/hooks/use-user' import { ResolutionPanel } from 'web/components/resolution-panel' import { Spacer } from 'web/components/layout/spacer' import { Contract, getContractFromSlug, tradingAllowed, - getBinaryProbPercent, } from 'web/lib/firebase/contracts' import { SEO } from 'web/components/SEO' import { Page } from 'web/components/page' @@ -21,26 +21,29 @@ import { Comment, listAllComments } from 'web/lib/firebase/comments' import Custom404 from '../404' import { AnswersPanel } from 'web/components/answers/answers-panel' import { fromPropz, usePropz } from 'web/hooks/use-propz' +import { Leaderboard } from 'web/components/leaderboard' +import { resolvedPayout } from 'common/calculate' +import { formatMoney } from 'common/util/format' import { ContractTabs } from 'web/components/contract/contract-tabs' -import { contractTextDetails } from 'web/components/contract/contract-details' import { useWindowSize } from 'web/hooks/use-window-size' import Confetti from 'react-confetti' -import { NumericBetPanel } from '../../components/numeric-bet-panel' -import { NumericResolutionPanel } from '../../components/numeric-resolution-panel' +import { NumericBetPanel } from 'web/components/numeric-bet-panel' +import { NumericResolutionPanel } from 'web/components/numeric-resolution-panel' import { useIsIframe } from 'web/hooks/use-is-iframe' import ContractEmbedPage from '../embed/[username]/[contractSlug]' import { useBets } from 'web/hooks/use-bets' import { CPMMBinaryContract } from 'common/contract' import { AlertBox } from 'web/components/alert-box' import { useTracking } from 'web/hooks/use-tracking' -import { useTipTxns } from 'web/hooks/use-tip-txns' +import { CommentTipMap, useTipTxns } from 'web/hooks/use-tip-txns' import { useLiquidity } from 'web/hooks/use-liquidity' -import { richTextToString } from 'common/util/parse' import { useSaveReferral } from 'web/hooks/use-save-referral' -import { - ContractLeaderboard, - ContractTopTrades, -} from 'web/components/contract/contract-leaderboard' +import { getOpenGraphProps } from 'web/components/contract/contract-card-preview' +import { User } from 'common/user' +import { listUsers } from 'web/lib/firebase/users' +import { FeedComment } from 'web/components/feed/feed-comments' +import { Title } from 'web/components/title' +import { FeedBet } from 'web/components/feed/feed-bets' export const getStaticProps = fromPropz(getStaticPropz) export async function getStaticPropz(props: { @@ -153,7 +156,7 @@ export function ContractPageContent( const ogCardProps = getOpenGraphProps(contract) useSaveReferral(user, { - defaultReferrer: contract.creatorUsername, + defaultReferrerUsername: contract.creatorUsername, contractId: contract.id, }) @@ -208,7 +211,10 @@ export function ContractPageContent( </button> )} - <ContractOverview contract={contract} bets={bets} /> + <ContractOverview + contract={contract} + bets={bets.filter((b) => !b.challengeSlug)} + /> {isNumeric && ( <AlertBox @@ -258,34 +264,125 @@ export function ContractPageContent( ) } -const getOpenGraphProps = (contract: Contract) => { - const { - resolution, - question, - creatorName, - creatorUsername, - outcomeType, - creatorAvatarUrl, - description: desc, - } = contract - const probPercent = - outcomeType === 'BINARY' ? getBinaryProbPercent(contract) : undefined +function ContractLeaderboard(props: { contract: Contract; bets: Bet[] }) { + const { contract, bets } = props + const [users, setUsers] = useState<User[]>() - const stringDesc = typeof desc === 'string' ? desc : richTextToString(desc) + const { userProfits, top5Ids } = useMemo(() => { + // Create a map of userIds to total profits (including sales) + const openBets = bets.filter((bet) => !bet.isSold && !bet.sale) + const betsByUser = groupBy(openBets, 'userId') - const description = resolution - ? `Resolved ${resolution}. ${stringDesc}` - : probPercent - ? `${probPercent} chance. ${stringDesc}` - : stringDesc + const userProfits = mapValues(betsByUser, (bets) => + sumBy(bets, (bet) => resolvedPayout(contract, bet) - bet.amount) + ) + // Find the 5 users with the most profits + const top5Ids = Object.entries(userProfits) + .sort(([_i1, p1], [_i2, p2]) => p2 - p1) + .filter(([, p]) => p > 0) + .slice(0, 5) + .map(([id]) => id) + return { userProfits, top5Ids } + }, [contract, bets]) - return { - question, - probability: probPercent, - metadata: contractTextDetails(contract), - creatorName, - creatorUsername, - creatorAvatarUrl, - description, - } + useEffect(() => { + if (top5Ids.length > 0) { + listUsers(top5Ids).then((users) => { + const sortedUsers = sortBy(users, (user) => -userProfits[user.id]) + setUsers(sortedUsers) + }) + } + }, [userProfits, top5Ids]) + + return users && users.length > 0 ? ( + <Leaderboard + title="🏅 Top bettors" + users={users || []} + columns={[ + { + header: 'Total profit', + renderCell: (user) => formatMoney(userProfits[user.id] || 0), + }, + ]} + className="mt-12 max-w-sm" + /> + ) : null +} + +function ContractTopTrades(props: { + contract: Contract + bets: Bet[] + comments: Comment[] + tips: CommentTipMap +}) { + const { contract, bets, comments, tips } = props + const commentsById = keyBy(comments, 'id') + const betsById = keyBy(bets, 'id') + + // If 'id2' is the sale of 'id1', both are logged with (id2 - id1) of profit + // Otherwise, we record the profit at resolution time + const profitById: Record<string, number> = {} + for (const bet of bets) { + if (bet.sale) { + const originalBet = betsById[bet.sale.betId] + const profit = bet.sale.amount - originalBet.amount + profitById[bet.id] = profit + profitById[originalBet.id] = profit + } else { + profitById[bet.id] = resolvedPayout(contract, bet) - bet.amount + } + } + + // Now find the betId with the highest profit + const topBetId = sortBy(bets, (b) => -profitById[b.id])[0]?.id + const topBettor = useUserById(betsById[topBetId]?.userId) + + // And also the commentId of the comment with the highest profit + const topCommentId = sortBy( + comments, + (c) => c.betId && -profitById[c.betId] + )[0]?.id + + return ( + <div className="mt-12 max-w-sm"> + {topCommentId && profitById[topCommentId] > 0 && ( + <> + <Title text="💬 Proven correct" className="!mt-0" /> + <div className="relative flex items-start space-x-3 rounded-md bg-gray-50 px-2 py-4"> + <FeedComment + contract={contract} + comment={commentsById[topCommentId]} + tips={tips[topCommentId]} + betsBySameUser={[betsById[topCommentId]]} + truncate={false} + smallAvatar={false} + /> + </div> + <div className="mt-2 text-sm text-gray-500"> + {commentsById[topCommentId].userName} made{' '} + {formatMoney(profitById[topCommentId] || 0)}! + </div> + <Spacer h={16} /> + </> + )} + + {/* If they're the same, only show the comment; otherwise show both */} + {topBettor && topBetId !== topCommentId && profitById[topBetId] > 0 && ( + <> + <Title text="💸 Smartest money" className="!mt-0" /> + <div className="relative flex items-start space-x-3 rounded-md bg-gray-50 px-2 py-4"> + <FeedBet + contract={contract} + bet={betsById[topBetId]} + hideOutcome={false} + smallAvatar={false} + /> + </div> + <div className="mt-2 text-sm text-gray-500"> + {topBettor?.name} made {formatMoney(profitById[topBetId] || 0)}! + </div> + </> + )} + </div> + ) } diff --git a/web/pages/challenges/[username]/[contractSlug]/[challengeSlug].tsx b/web/pages/challenges/[username]/[contractSlug]/[challengeSlug].tsx new file mode 100644 index 00000000..baf68e2a --- /dev/null +++ b/web/pages/challenges/[username]/[contractSlug]/[challengeSlug].tsx @@ -0,0 +1,403 @@ +import React, { useEffect, useState } from 'react' +import Confetti from 'react-confetti' + +import { fromPropz, usePropz } from 'web/hooks/use-propz' +import { contractPath, getContractFromSlug } from 'web/lib/firebase/contracts' +import { useContractWithPreload } from 'web/hooks/use-contract' +import { DOMAIN } from 'common/envs/constants' +import { Col } from 'web/components/layout/col' +import { SiteLink } from 'web/components/site-link' +import { Spacer } from 'web/components/layout/spacer' +import { Row } from 'web/components/layout/row' +import { Challenge } from 'common/challenge' +import { + getChallenge, + getChallengeUrl, + useChallenge, +} from 'web/lib/firebase/challenges' +import { getUserByUsername } from 'web/lib/firebase/users' +import { User } from 'common/user' +import { Page } from 'web/components/page' +import { useUser, useUserById } from 'web/hooks/use-user' +import { AcceptChallengeButton } from 'web/components/challenges/accept-challenge-button' +import { Avatar } from 'web/components/avatar' +import { UserLink } from 'web/components/user-page' +import { BinaryOutcomeLabel } from 'web/components/outcome-label' +import { formatMoney } from 'common/util/format' +import { LoadingIndicator } from 'web/components/loading-indicator' +import { useWindowSize } from 'web/hooks/use-window-size' +import { Bet, listAllBets } from 'web/lib/firebase/bets' +import { SEO } from 'web/components/SEO' +import { getOpenGraphProps } from 'web/components/contract/contract-card-preview' +import Custom404 from 'web/pages/404' +import { useSaveReferral } from 'web/hooks/use-save-referral' +import { BinaryContract } from 'common/contract' +import { Title } from 'web/components/title' + +export const getStaticProps = fromPropz(getStaticPropz) + +export async function getStaticPropz(props: { + params: { username: string; contractSlug: string; challengeSlug: string } +}) { + const { username, contractSlug, challengeSlug } = props.params + const contract = (await getContractFromSlug(contractSlug)) || null + const user = (await getUserByUsername(username)) || null + const bets = contract?.id ? await listAllBets(contract.id) : [] + const challenge = contract?.id + ? await getChallenge(challengeSlug, contract.id) + : null + + return { + props: { + contract, + user, + slug: contractSlug, + challengeSlug, + bets, + challenge, + }, + + revalidate: 60, // regenerate after a minute + } +} + +export async function getStaticPaths() { + return { paths: [], fallback: 'blocking' } +} + +export default function ChallengePage(props: { + contract: BinaryContract | null + user: User + slug: string + bets: Bet[] + challenge: Challenge | null + challengeSlug: string +}) { + props = usePropz(props, getStaticPropz) ?? { + contract: null, + user: null, + challengeSlug: '', + bets: [], + challenge: null, + slug: '', + } + const contract = (useContractWithPreload(props.contract) ?? + props.contract) as BinaryContract + + const challenge = + useChallenge(props.challengeSlug, contract?.id) ?? props.challenge + + const { user, bets } = props + const currentUser = useUser() + + useSaveReferral(currentUser, { + defaultReferrerUsername: challenge?.creatorUsername, + }) + + if (!contract || !challenge) return <Custom404 /> + + const ogCardProps = getOpenGraphProps(contract) + ogCardProps.creatorUsername = challenge.creatorUsername + ogCardProps.creatorName = challenge.creatorName + ogCardProps.creatorAvatarUrl = challenge.creatorAvatarUrl + + return ( + <Page> + <SEO + title={ogCardProps.question} + description={ogCardProps.description} + url={getChallengeUrl(challenge).replace('https://', '')} + ogCardProps={ogCardProps} + challenge={challenge} + /> + {challenge.acceptances.length >= challenge.maxUses ? ( + <ClosedChallengeContent + contract={contract} + challenge={challenge} + creator={user} + /> + ) : ( + <OpenChallengeContent + user={currentUser} + contract={contract} + challenge={challenge} + creator={user} + bets={bets} + /> + )} + + <FAQ /> + </Page> + ) +} + +function FAQ() { + const [toggleWhatIsThis, setToggleWhatIsThis] = useState(false) + const [toggleWhatIsMana, setToggleWhatIsMana] = useState(false) + return ( + <Col className={'items-center gap-4 p-2 md:p-6 lg:items-start'}> + <Row className={'text-xl text-indigo-700'}>FAQ</Row> + <Row className={'text-lg text-indigo-700'}> + <span + className={'mx-2 cursor-pointer'} + onClick={() => setToggleWhatIsThis(!toggleWhatIsThis)} + > + {toggleWhatIsThis ? '-' : '+'} + What is this? + </span> + </Row> + {toggleWhatIsThis && ( + <Row className={'mx-4'}> + <span> + This is a challenge bet, or a bet offered from one person to another + that is only realized if both parties agree. You can agree to the + challenge (if it's open) or create your own from a market page. See + more markets{' '} + <SiteLink className={'font-bold'} href={'/home'}> + here. + </SiteLink> + </span> + </Row> + )} + <Row className={'text-lg text-indigo-700'}> + <span + className={'mx-2 cursor-pointer'} + onClick={() => setToggleWhatIsMana(!toggleWhatIsMana)} + > + {toggleWhatIsMana ? '-' : '+'} + What is M$? + </span> + </Row> + {toggleWhatIsMana && ( + <Row className={'mx-4'}> + Mana (M$) is the play-money used by our platform to keep track of your + bets. It's completely free for you and your friends to get started! + </Row> + )} + </Col> + ) +} + +function ClosedChallengeContent(props: { + contract: BinaryContract + challenge: Challenge + creator: User +}) { + const { contract, challenge, creator } = props + const { resolution, question } = contract + const { + acceptances, + creatorAmount, + creatorOutcome, + acceptorOutcome, + acceptorAmount, + } = challenge + + const user = useUserById(acceptances[0].userId) + + const [showConfetti, setShowConfetti] = useState(false) + const { width, height } = useWindowSize() + useEffect(() => { + if (acceptances.length === 0) return + if (acceptances[0].createdTime > Date.now() - 1000 * 60) + setShowConfetti(true) + }, [acceptances]) + + const creatorWon = resolution === creatorOutcome + + const href = `https://${DOMAIN}${contractPath(contract)}` + + if (!user) return <LoadingIndicator /> + + const winner = (creatorWon ? creator : user).name + + return ( + <> + {showConfetti && ( + <Confetti + width={width ?? 500} + height={height ?? 500} + confettiSource={{ + x: ((width ?? 500) - 200) / 2, + y: 0, + w: 200, + h: 0, + }} + recycle={false} + initialVelocityY={{ min: 1, max: 3 }} + numberOfPieces={200} + /> + )} + <Col className=" w-full items-center justify-center rounded border-0 border-gray-100 bg-white py-6 pl-1 pr-2 sm:px-2 md:px-6 md:py-8 "> + {resolution ? ( + <> + <Title className="!mt-0" text={`🥇 ${winner} wins the bet 🥇`} /> + <SiteLink href={href} className={'mb-8 text-xl'}> + {question} + </SiteLink> + </> + ) : ( + <SiteLink href={href} className={'mb-8'}> + <span className="text-3xl text-indigo-700">{question}</span> + </SiteLink> + )} + <Col + className={'w-full content-between justify-between gap-1 sm:flex-row'} + > + <UserBetColumn + challenger={creator} + outcome={creatorOutcome} + amount={creatorAmount} + isResolved={!!resolution} + /> + + <Col className="items-center justify-center py-8 text-2xl sm:text-4xl"> + VS + </Col> + + <UserBetColumn + challenger={user?.id === creator.id ? undefined : user} + outcome={acceptorOutcome} + amount={acceptorAmount} + isResolved={!!resolution} + /> + </Col> + <Spacer h={3} /> + + {/* <Row className="mt-8 items-center"> + <span className='mr-4'>Share</span> <CopyLinkButton url={window.location.href} /> + </Row> */} + </Col> + </> + ) +} + +function OpenChallengeContent(props: { + contract: BinaryContract + challenge: Challenge + creator: User + user: User | null | undefined + bets: Bet[] +}) { + const { contract, challenge, creator, user } = props + const { question } = contract + const { + creatorAmount, + creatorId, + creatorOutcome, + acceptorAmount, + acceptorOutcome, + } = challenge + + const href = `https://${DOMAIN}${contractPath(contract)}` + + return ( + <Col className="items-center"> + <Col className="h-full items-center justify-center rounded bg-white p-4 py-8 sm:p-8 sm:shadow-md"> + <SiteLink href={href} className={'mb-8'}> + <span className="text-3xl text-indigo-700">{question}</span> + </SiteLink> + + <Col + className={ + 'h-full max-h-[50vh] w-full content-between justify-between gap-1 sm:flex-row' + } + > + <UserBetColumn + challenger={creator} + outcome={creatorOutcome} + amount={creatorAmount} + /> + + <Col className="items-center justify-center py-4 text-2xl sm:py-8 sm:text-4xl"> + VS + </Col> + + <UserBetColumn + challenger={user?.id === creatorId ? undefined : user} + outcome={acceptorOutcome} + amount={acceptorAmount} + /> + </Col> + + <Spacer h={3} /> + <Row className={'my-4 text-center text-gray-500'}> + <span> + {`${creator.name} will bet ${formatMoney( + creatorAmount + )} on ${creatorOutcome} if you bet ${formatMoney( + acceptorAmount + )} on ${acceptorOutcome}. Whoever is right will get `} + <span className="mr-1 font-bold "> + {formatMoney(creatorAmount + acceptorAmount)} + </span> + total. + </span> + </Row> + + <Row className="my-4 w-full items-center justify-center"> + <AcceptChallengeButton + user={user} + contract={contract} + challenge={challenge} + /> + </Row> + </Col> + </Col> + ) +} + +const userCol = (challenger: User) => ( + <Col className={'mb-2 w-full items-center justify-center gap-2'}> + <UserLink + className={'text-2xl'} + name={challenger.name} + username={challenger.username} + /> + <Avatar + size={24} + avatarUrl={challenger.avatarUrl} + username={challenger.username} + /> + </Col> +) + +function UserBetColumn(props: { + challenger: User | null | undefined + outcome: string + amount: number + isResolved?: boolean +}) { + const { challenger, outcome, amount, isResolved } = props + + return ( + <Col className="w-full items-start justify-center gap-1"> + {challenger ? ( + userCol(challenger) + ) : ( + <Col className={'mb-2 w-full items-center justify-center gap-2'}> + <span className={'text-2xl'}>You</span> + <Avatar + className={'h-[7.25rem] w-[7.25rem]'} + avatarUrl={undefined} + username={undefined} + /> + </Col> + )} + <Row className={'w-full items-center justify-center'}> + <span className={'text-lg'}> + {isResolved ? 'had bet' : challenger ? '' : ''} + </span> + </Row> + <Row className={'w-full items-center justify-center'}> + <span className={'text-lg'}> + <span className="bold text-2xl">{formatMoney(amount)}</span> + {' on '} + <span className="bold text-2xl"> + <BinaryOutcomeLabel outcome={outcome as any} /> + </span>{' '} + </span> + </Row> + </Col> + ) +} diff --git a/web/pages/challenges/index.tsx b/web/pages/challenges/index.tsx new file mode 100644 index 00000000..40e00084 --- /dev/null +++ b/web/pages/challenges/index.tsx @@ -0,0 +1,300 @@ +import clsx from 'clsx' +import React from 'react' +import { formatMoney } from 'common/util/format' +import { Col } from 'web/components/layout/col' +import { Row } from 'web/components/layout/row' +import { Page } from 'web/components/page' +import { SEO } from 'web/components/SEO' +import { Title } from 'web/components/title' +import { useUser } from 'web/hooks/use-user' +import { fromNow } from 'web/lib/util/time' + +import dayjs from 'dayjs' +import customParseFormat from 'dayjs/plugin/customParseFormat' +import { + getChallengeUrl, + useAcceptedChallenges, + useUserChallenges, +} from 'web/lib/firebase/challenges' +import { Challenge } from 'common/challenge' +import { Tabs } from 'web/components/layout/tabs' +import { SiteLink } from 'web/components/site-link' +import { UserLink } from 'web/components/user-page' +import { Avatar } from 'web/components/avatar' +import Router from 'next/router' +import { contractPathWithoutContract } from 'web/lib/firebase/contracts' +import { Button } from 'web/components/button' +import { ClipboardCopyIcon, QrcodeIcon } from '@heroicons/react/outline' +import { copyToClipboard } from 'web/lib/util/copy' +import toast from 'react-hot-toast' +import { Modal } from 'web/components/layout/modal' +import { QRCode } from 'web/components/qr-code' + +dayjs.extend(customParseFormat) +const columnClass = 'sm:px-5 px-2 py-3.5 max-w-[100px] truncate' +const amountClass = columnClass + ' max-w-[75px] font-bold' + +export default function ChallengesListPage() { + const user = useUser() + const userChallenges = useUserChallenges(user?.id ?? '') + const challenges = useAcceptedChallenges() + + const userTab = user + ? [ + { + content: <YourChallengesTable links={userChallenges} />, + title: 'Your Challenges', + }, + ] + : [] + + const publicTab = [ + { + content: <PublicChallengesTable links={challenges} />, + title: 'Public Challenges', + }, + ] + + return ( + <Page> + <SEO + title="Challenges" + description="Challenge your friends to a bet!" + url="/send" + /> + + <Col className="w-full px-8"> + <Row className="items-center justify-between"> + <Title text="Challenges" /> + </Row> + <p>Find or create a question to challenge someone to a bet.</p> + + <Tabs tabs={[...userTab, ...publicTab]} /> + </Col> + </Page> + ) +} + +function YourChallengesTable(props: { links: Challenge[] }) { + const { links } = props + return links.length == 0 ? ( + <p>There aren't currently any challenges.</p> + ) : ( + <div className="overflow-scroll"> + <table className="w-full divide-y divide-gray-300 rounded-lg border border-gray-200"> + <thead className="bg-gray-50 text-left text-sm font-semibold text-gray-900"> + <tr> + <th className={amountClass}>Amount</th> + <th + className={clsx( + columnClass, + 'text-center sm:pl-10 sm:text-start' + )} + > + Link + </th> + <th className={columnClass}>Accepted By</th> + </tr> + </thead> + <tbody className={'divide-y divide-gray-200 bg-white'}> + {links.map((link) => ( + <YourLinkSummaryRow challenge={link} /> + ))} + </tbody> + </table> + </div> + ) +} + +function YourLinkSummaryRow(props: { challenge: Challenge }) { + const { challenge } = props + const { acceptances } = challenge + const [open, setOpen] = React.useState(false) + const className = clsx( + 'whitespace-nowrap text-sm hover:cursor-pointer text-gray-500 hover:bg-sky-50 bg-white' + ) + return ( + <> + <Modal open={open} setOpen={setOpen} size={'sm'}> + <Col + className={ + 'items-center justify-center gap-4 rounded-md bg-white p-8 py-8 ' + } + > + <span className={'mb-4 text-center text-xl text-indigo-700'}> + Have your friend scan this to accept the challenge! + </span> + <QRCode url={getChallengeUrl(challenge)} /> + </Col> + </Modal> + <tr id={challenge.slug} key={challenge.slug} className={className}> + <td className={amountClass}> + <SiteLink href={getChallengeUrl(challenge)}> + {formatMoney(challenge.creatorAmount)} + </SiteLink> + </td> + <td + className={clsx( + columnClass, + 'text-center sm:max-w-[200px] sm:text-start' + )} + > + <Row className="items-center gap-2"> + <Button + color="gray-white" + size="xs" + onClick={() => { + copyToClipboard(getChallengeUrl(challenge)) + toast('Link copied to clipboard!') + }} + > + <ClipboardCopyIcon className={'h-5 w-5 sm:h-4 sm:w-4'} /> + </Button> + <Button + color="gray-white" + size="xs" + onClick={() => { + setOpen(true) + }} + > + <QrcodeIcon className="h-5 w-5 sm:h-4 sm:w-4" /> + </Button> + <SiteLink + href={getChallengeUrl(challenge)} + className={'mx-1 mb-1 hidden sm:inline-block'} + > + {`...${challenge.contractSlug}/${challenge.slug}`} + </SiteLink> + </Row> + </td> + + <td className={columnClass}> + <Row className={'items-center justify-start gap-1'}> + {acceptances.length > 0 ? ( + <> + <Avatar + username={acceptances[0].userUsername} + avatarUrl={acceptances[0].userAvatarUrl} + size={'sm'} + /> + <UserLink + name={acceptances[0].userName} + username={acceptances[0].userUsername} + /> + </> + ) : ( + <span> + No one - + {challenge.expiresTime && + ` (expires ${fromNow(challenge.expiresTime)})`} + </span> + )} + </Row> + </td> + </tr> + </> + ) +} + +function PublicChallengesTable(props: { links: Challenge[] }) { + const { links } = props + return links.length == 0 ? ( + <p>There aren't currently any challenges.</p> + ) : ( + <div className="overflow-scroll"> + <table className="w-full divide-y divide-gray-300 rounded-lg border border-gray-200"> + <thead className="bg-gray-50 text-left text-sm font-semibold text-gray-900"> + <tr> + <th className={amountClass}>Amount</th> + <th className={columnClass}>Creator</th> + <th className={columnClass}>Acceptor</th> + <th className={columnClass}>Market</th> + </tr> + </thead> + <tbody className={'divide-y divide-gray-200 bg-white'}> + {links.map((link) => ( + <PublicLinkSummaryRow challenge={link} /> + ))} + </tbody> + </table> + </div> + ) +} + +function PublicLinkSummaryRow(props: { challenge: Challenge }) { + const { challenge } = props + const { + acceptances, + creatorUsername, + creatorName, + creatorAvatarUrl, + contractCreatorUsername, + contractQuestion, + contractSlug, + } = challenge + + const className = clsx( + 'whitespace-nowrap text-sm hover:cursor-pointer text-gray-500 hover:bg-sky-50 bg-white' + ) + return ( + <tr + id={challenge.slug + '-public'} + key={challenge.slug + '-public'} + className={className} + onClick={() => Router.push(getChallengeUrl(challenge))} + > + <td className={amountClass}> + <SiteLink href={getChallengeUrl(challenge)}> + {formatMoney(challenge.creatorAmount)} + </SiteLink> + </td> + + <td className={clsx(columnClass)}> + <Row className={'items-center justify-start gap-1'}> + <Avatar + username={creatorUsername} + avatarUrl={creatorAvatarUrl} + size={'sm'} + noLink={true} + /> + <UserLink name={creatorName} username={creatorUsername} /> + </Row> + </td> + + <td className={clsx(columnClass)}> + <Row className={'items-center justify-start gap-1'}> + {acceptances.length > 0 ? ( + <> + <Avatar + username={acceptances[0].userUsername} + avatarUrl={acceptances[0].userAvatarUrl} + size={'sm'} + noLink={true} + /> + <UserLink + name={acceptances[0].userName} + username={acceptances[0].userUsername} + /> + </> + ) : ( + <span> + No one - + {challenge.expiresTime && + ` (expires ${fromNow(challenge.expiresTime)})`} + </span> + )} + </Row> + </td> + <td className={clsx(columnClass, 'font-bold')}> + <SiteLink + href={contractPathWithoutContract( + contractCreatorUsername, + contractSlug + )} + > + {contractQuestion} + </SiteLink> + </td> + </tr> + ) +} diff --git a/web/pages/embed/[username]/[contractSlug].tsx b/web/pages/embed/[username]/[contractSlug].tsx index 57189c0c..d38c6e5b 100644 --- a/web/pages/embed/[username]/[contractSlug].tsx +++ b/web/pages/embed/[username]/[contractSlug].tsx @@ -21,8 +21,11 @@ import { useMeasureSize } from 'web/hooks/use-measure-size' import { fromPropz, usePropz } from 'web/hooks/use-propz' import { useWindowSize } from 'web/hooks/use-window-size' import { listAllBets } from 'web/lib/firebase/bets' -import { contractPath, getContractFromSlug } from 'web/lib/firebase/contracts' -import { tradingAllowed } from 'web/lib/firebase/contracts' +import { + contractPath, + getContractFromSlug, + tradingAllowed, +} from 'web/lib/firebase/contracts' import Custom404 from '../../404' export const getStaticProps = fromPropz(getStaticPropz) @@ -76,7 +79,7 @@ export default function ContractEmbedPage(props: { return <ContractEmbed contract={contract} bets={bets} /> } -function ContractEmbed(props: { contract: Contract; bets: Bet[] }) { +export function ContractEmbed(props: { contract: Contract; bets: Bet[] }) { const { contract, bets } = props const { question, outcomeType } = contract diff --git a/web/pages/group/[...slugs]/index.tsx b/web/pages/group/[...slugs]/index.tsx index 642a2afd..b96d6436 100644 --- a/web/pages/group/[...slugs]/index.tsx +++ b/web/pages/group/[...slugs]/index.tsx @@ -160,7 +160,7 @@ export default function GroupPage(props: { const privateUser = usePrivateUser(user?.id) useSaveReferral(user, { - defaultReferrer: creator.username, + defaultReferrerUsername: creator.username, groupId: group?.id, }) diff --git a/web/pages/link/[slug].tsx b/web/pages/link/[slug].tsx index c7457f27..d2b12065 100644 --- a/web/pages/link/[slug].tsx +++ b/web/pages/link/[slug].tsx @@ -91,5 +91,5 @@ const useReferral = (user: User | undefined | null, manalink?: Manalink) => { if (manalink?.fromId) getUser(manalink.fromId).then(setCreator) }, [manalink]) - useSaveReferral(user, { defaultReferrer: creator?.username }) + useSaveReferral(user, { defaultReferrerUsername: creator?.username }) } diff --git a/web/pages/notifications.tsx b/web/pages/notifications.tsx index 9f076c41..625c7c17 100644 --- a/web/pages/notifications.tsx +++ b/web/pages/notifications.tsx @@ -811,6 +811,7 @@ function getSourceUrl(notification: Notification) { if (sourceType === 'tip' && sourceContractSlug) return `/${sourceContractCreatorUsername}/${sourceContractSlug}#${sourceSlug}` if (sourceType === 'tip' && sourceSlug) return `${groupPath(sourceSlug)}` + if (sourceType === 'challenge') return `${sourceSlug}` if (sourceContractCreatorUsername && sourceContractSlug) return `/${sourceContractCreatorUsername}/${sourceContractSlug}#${getSourceIdForLinkComponent( sourceId ?? '', @@ -913,6 +914,15 @@ function NotificationTextLabel(props: { <span>of your limit order was filled</span> </> ) + } else if (sourceType === 'challenge' && sourceText) { + return ( + <> + <span> for </span> + <span className="text-primary"> + {formatMoney(parseInt(sourceText))} + </span> + </> + ) } return ( <div className={className ? className : 'line-clamp-4 whitespace-pre-line'}> @@ -967,6 +977,9 @@ function getReasonForShowingNotification( case 'bet': reasonText = 'bet against you' break + case 'challenge': + reasonText = 'accepted your challenge' + break default: reasonText = '' } From c93f9c54831eb7d432208f21b309a22640b0bed6 Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Thu, 4 Aug 2022 15:58:48 -0600 Subject: [PATCH 41/83] See challenges you've accepted too --- web/lib/firebase/challenges.ts | 4 ++-- web/pages/challenges/index.tsx | 6 +++++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/web/lib/firebase/challenges.ts b/web/lib/firebase/challenges.ts index d62d5aac..89da7f80 100644 --- a/web/lib/firebase/challenges.ts +++ b/web/lib/firebase/challenges.ts @@ -134,11 +134,11 @@ export function listenForUserChallenges( return listenForValues<Challenge>(listUserChallenges(fromId), setLinks) } -export const useUserChallenges = (fromId: string) => { +export const useUserChallenges = (fromId?: string) => { const [links, setLinks] = useState<Challenge[]>([]) useEffect(() => { - return listenForUserChallenges(fromId, setLinks) + if (fromId) return listenForUserChallenges(fromId, setLinks) }, [fromId]) return links diff --git a/web/pages/challenges/index.tsx b/web/pages/challenges/index.tsx index 40e00084..7c68f0bd 100644 --- a/web/pages/challenges/index.tsx +++ b/web/pages/challenges/index.tsx @@ -36,8 +36,12 @@ const amountClass = columnClass + ' max-w-[75px] font-bold' export default function ChallengesListPage() { const user = useUser() - const userChallenges = useUserChallenges(user?.id ?? '') const challenges = useAcceptedChallenges() + const userChallenges = useUserChallenges(user?.id) + .concat( + user ? challenges.filter((c) => c.acceptances[0].userId === user.id) : [] + ) + .sort((a, b) => b.createdTime - a.createdTime) const userTab = user ? [ From 912ccad53053db9647a49ab0310c42d2f874db3d Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Thu, 4 Aug 2022 16:09:33 -0600 Subject: [PATCH 42/83] Remove max height --- .../challenges/[username]/[contractSlug]/[challengeSlug].tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/pages/challenges/[username]/[contractSlug]/[challengeSlug].tsx b/web/pages/challenges/[username]/[contractSlug]/[challengeSlug].tsx index baf68e2a..0df5b7d7 100644 --- a/web/pages/challenges/[username]/[contractSlug]/[challengeSlug].tsx +++ b/web/pages/challenges/[username]/[contractSlug]/[challengeSlug].tsx @@ -300,7 +300,7 @@ function OpenChallengeContent(props: { <Col className={ - 'h-full max-h-[50vh] w-full content-between justify-between gap-1 sm:flex-row' + ' w-full content-between justify-between gap-1 sm:flex-row' } > <UserBetColumn From edae709f5f25c192e386dded4838182684c9e6d9 Mon Sep 17 00:00:00 2001 From: Sinclair Chen <abc.sinclair@gmail.com> Date: Thu, 4 Aug 2022 15:35:55 -0700 Subject: [PATCH 43/83] Notify mentioned users on market publish (#683) * Add function to parse at mentions * Notify mentioned users on market create - refactor createNotification to accept list of recipients' ids --- common/util/parse.ts | 10 +++ functions/src/create-notification.ts | 66 ++++++++++--------- .../src/on-create-comment-on-contract.ts | 5 +- functions/src/on-create-contract.ts | 9 ++- functions/src/on-create-group.ts | 28 ++++---- functions/src/on-follow-user.ts | 2 +- 6 files changed, 68 insertions(+), 52 deletions(-) diff --git a/common/util/parse.ts b/common/util/parse.ts index cacd0862..f07e4097 100644 --- a/common/util/parse.ts +++ b/common/util/parse.ts @@ -22,6 +22,7 @@ import { Image } from '@tiptap/extension-image' import { Link } from '@tiptap/extension-link' import { Mention } from '@tiptap/extension-mention' import Iframe from './tiptap-iframe' +import { uniq } from 'lodash' export function parseTags(text: string) { const regex = /(?:^|\s)(?:[#][a-z0-9_]+)/gi @@ -61,6 +62,15 @@ const checkAgainstQuery = (query: string, corpus: string) => export const searchInAny = (query: string, ...fields: string[]) => fields.some((field) => checkAgainstQuery(query, field)) +/** @return user ids of all \@mentions */ +export function parseMentions(data: JSONContent): string[] { + const mentions = data.content?.flatMap(parseMentions) ?? [] //dfs + if (data.type === 'mention' && data.attrs) { + mentions.push(data.attrs.id as string) + } + return uniq(mentions) +} + // can't just do [StarterKit, Image...] because it doesn't work with cjs imports export const exhibitExts = [ Blockquote, diff --git a/functions/src/create-notification.ts b/functions/src/create-notification.ts index 83568535..e16920f7 100644 --- a/functions/src/create-notification.ts +++ b/functions/src/create-notification.ts @@ -33,7 +33,7 @@ export const createNotification = async ( miscData?: { contract?: Contract relatedSourceType?: notification_source_types - relatedUserId?: string + recipients?: string[] slug?: string title?: string } @@ -41,7 +41,7 @@ export const createNotification = async ( const { contract: sourceContract, relatedSourceType, - relatedUserId, + recipients, slug, title, } = miscData ?? {} @@ -128,7 +128,7 @@ export const createNotification = async ( }) } - const notifyRepliedUsers = async ( + const notifyRepliedUser = ( userToReasonTexts: user_to_reason_texts, relatedUserId: string, relatedSourceType: notification_source_types @@ -145,7 +145,7 @@ export const createNotification = async ( } } - const notifyFollowedUser = async ( + const notifyFollowedUser = ( userToReasonTexts: user_to_reason_texts, followedUserId: string ) => { @@ -155,21 +155,24 @@ export const createNotification = async ( } } - const notifyTaggedUsers = async ( - userToReasonTexts: user_to_reason_texts, - sourceText: string - ) => { - const taggedUsers = sourceText.match(/@\w+/g) - if (!taggedUsers) return - // await all get tagged users: - const users = await Promise.all( - taggedUsers.map(async (username) => { - return await getUserByUsername(username.slice(1)) - }) + /** @deprecated parse from rich text instead */ + const parseMentions = async (source: string) => { + const mentions = source.match(/@\w+/g) + if (!mentions) return [] + return Promise.all( + mentions.map( + async (username) => (await getUserByUsername(username.slice(1)))?.id + ) ) - users.forEach((taggedUser) => { - if (taggedUser && shouldGetNotification(taggedUser.id, userToReasonTexts)) - userToReasonTexts[taggedUser.id] = { + } + + const notifyTaggedUsers = ( + userToReasonTexts: user_to_reason_texts, + userIds: (string | undefined)[] + ) => { + userIds.forEach((id) => { + if (id && shouldGetNotification(id, userToReasonTexts)) + userToReasonTexts[id] = { reason: 'tagged_user', } }) @@ -254,7 +257,7 @@ export const createNotification = async ( }) } - const notifyUserAddedToGroup = async ( + const notifyUserAddedToGroup = ( userToReasonTexts: user_to_reason_texts, relatedUserId: string ) => { @@ -276,11 +279,14 @@ export const createNotification = async ( const getUsersToNotify = async () => { const userToReasonTexts: user_to_reason_texts = {} // The following functions modify the userToReasonTexts object in place. - if (sourceType === 'follow' && relatedUserId) { - await notifyFollowedUser(userToReasonTexts, relatedUserId) - } else if (sourceType === 'group' && relatedUserId) { - if (sourceUpdateType === 'created') - await notifyUserAddedToGroup(userToReasonTexts, relatedUserId) + if (sourceType === 'follow' && recipients?.[0]) { + notifyFollowedUser(userToReasonTexts, recipients[0]) + } else if ( + sourceType === 'group' && + sourceUpdateType === 'created' && + recipients + ) { + recipients.forEach((r) => notifyUserAddedToGroup(userToReasonTexts, r)) } // The following functions need sourceContract to be defined. @@ -293,13 +299,10 @@ export const createNotification = async ( (sourceUpdateType === 'updated' || sourceUpdateType === 'resolved')) ) { if (sourceType === 'comment') { - if (relatedUserId && relatedSourceType) - await notifyRepliedUsers( - userToReasonTexts, - relatedUserId, - relatedSourceType - ) - if (sourceText) await notifyTaggedUsers(userToReasonTexts, sourceText) + if (recipients?.[0] && relatedSourceType) + notifyRepliedUser(userToReasonTexts, recipients[0], relatedSourceType) + if (sourceText) + notifyTaggedUsers(userToReasonTexts, await parseMentions(sourceText)) } await notifyContractCreator(userToReasonTexts, sourceContract) await notifyOtherAnswerersOnContract(userToReasonTexts, sourceContract) @@ -308,6 +311,7 @@ export const createNotification = async ( await notifyOtherCommentersOnContract(userToReasonTexts, sourceContract) } else if (sourceType === 'contract' && sourceUpdateType === 'created') { await notifyUsersFollowers(userToReasonTexts) + notifyTaggedUsers(userToReasonTexts, recipients ?? []) } else if (sourceType === 'contract' && sourceUpdateType === 'closed') { await notifyContractCreator(userToReasonTexts, sourceContract, { force: true, diff --git a/functions/src/on-create-comment-on-contract.ts b/functions/src/on-create-comment-on-contract.ts index 8d841ac0..4719fd08 100644 --- a/functions/src/on-create-comment-on-contract.ts +++ b/functions/src/on-create-comment-on-contract.ts @@ -68,9 +68,10 @@ export const onCreateCommentOnContract = functions ? 'answer' : undefined - const relatedUserId = comment.replyToCommentId + const repliedUserId = comment.replyToCommentId ? comments.find((c) => c.id === comment.replyToCommentId)?.userId : answer?.userId + const recipients = repliedUserId ? [repliedUserId] : [] await createNotification( comment.id, @@ -79,7 +80,7 @@ export const onCreateCommentOnContract = functions commentCreator, eventId, comment.text, - { contract, relatedSourceType, relatedUserId } + { contract, relatedSourceType, recipients } ) const recipientUserIds = uniq([ diff --git a/functions/src/on-create-contract.ts b/functions/src/on-create-contract.ts index a43beda7..6b57a9a0 100644 --- a/functions/src/on-create-contract.ts +++ b/functions/src/on-create-contract.ts @@ -2,7 +2,7 @@ import * as functions from 'firebase-functions' import { getUser } from './utils' import { createNotification } from './create-notification' import { Contract } from '../../common/contract' -import { richTextToString } from '../../common/util/parse' +import { parseMentions, richTextToString } from '../../common/util/parse' import { JSONContent } from '@tiptap/core' export const onCreateContract = functions.firestore @@ -14,13 +14,16 @@ export const onCreateContract = functions.firestore const contractCreator = await getUser(contract.creatorId) if (!contractCreator) throw new Error('Could not find contract creator') + const desc = contract.description as JSONContent + const mentioned = parseMentions(desc) + await createNotification( contract.id, 'contract', 'created', contractCreator, eventId, - richTextToString(contract.description as JSONContent), - { contract } + richTextToString(desc), + { contract, recipients: mentioned } ) }) diff --git a/functions/src/on-create-group.ts b/functions/src/on-create-group.ts index 47618d7a..5209788d 100644 --- a/functions/src/on-create-group.ts +++ b/functions/src/on-create-group.ts @@ -12,19 +12,17 @@ export const onCreateGroup = functions.firestore const groupCreator = await getUser(group.creatorId) if (!groupCreator) throw new Error('Could not find group creator') // create notifications for all members of the group - for (const memberId of group.memberIds) { - await createNotification( - group.id, - 'group', - 'created', - groupCreator, - eventId, - group.about, - { - relatedUserId: memberId, - slug: group.slug, - title: group.name, - } - ) - } + await createNotification( + group.id, + 'group', + 'created', + groupCreator, + eventId, + group.about, + { + recipients: group.memberIds, + slug: group.slug, + title: group.name, + } + ) }) diff --git a/functions/src/on-follow-user.ts b/functions/src/on-follow-user.ts index 9a6e6dce..52042345 100644 --- a/functions/src/on-follow-user.ts +++ b/functions/src/on-follow-user.ts @@ -30,7 +30,7 @@ export const onFollowUser = functions.firestore followingUser, eventId, '', - { relatedUserId: follow.userId } + { recipients: [follow.userId] } ) }) From f52da72115bfacb0af5a4d54c137a936b33d9eee Mon Sep 17 00:00:00 2001 From: Sinclair Chen <abc.sinclair@gmail.com> Date: Thu, 4 Aug 2022 16:34:04 -0700 Subject: [PATCH 44/83] Switch comments/chat to rich text editor (#703) * Switch comments/chat to rich text editor * Remove TruncatedComment * Re-add submit on enter * Insert at mention on reply * Update editor style for send button * only submit on enter in chat * code review: refactor * use more specific type for upload * fix ESlint and errors from merge * fix trigger on every render eslint warning * Notify people mentioned in comment * fix type errors --- common/comment.ts | 6 +- functions/src/create-notification.ts | 19 +- functions/src/emails.ts | 4 +- .../src/on-create-comment-on-contract.ts | 11 +- web/components/comments-list.tsx | 7 +- .../contract/contract-leaderboard.tsx | 1 - web/components/editor.tsx | 30 +-- .../feed/feed-answer-comment-group.tsx | 28 +- web/components/feed/feed-comments.tsx | 242 +++++++----------- web/components/groups/group-chat.tsx | 104 ++++---- web/lib/firebase/comments.ts | 9 +- web/pages/[username]/[contractSlug].tsx | 1 - 12 files changed, 196 insertions(+), 266 deletions(-) diff --git a/common/comment.ts b/common/comment.ts index 0d0c4daf..a217b292 100644 --- a/common/comment.ts +++ b/common/comment.ts @@ -1,3 +1,5 @@ +import type { JSONContent } from '@tiptap/core' + // Currently, comments are created after the bet, not atomically with the bet. // They're uniquely identified by the pair contractId/betId. export type Comment = { @@ -9,7 +11,9 @@ export type Comment = { replyToCommentId?: string userId: string - text: string + /** @deprecated - content now stored as JSON in content*/ + text?: string + content: JSONContent createdTime: number // Denormalized, for rendering comments diff --git a/functions/src/create-notification.ts b/functions/src/create-notification.ts index e16920f7..6e312906 100644 --- a/functions/src/create-notification.ts +++ b/functions/src/create-notification.ts @@ -7,7 +7,7 @@ import { } from '../../common/notification' import { User } from '../../common/user' import { Contract } from '../../common/contract' -import { getUserByUsername, getValues } from './utils' +import { getValues } from './utils' import { Comment } from '../../common/comment' import { uniq } from 'lodash' import { Bet, LimitBet } from '../../common/bet' @@ -17,6 +17,7 @@ import { removeUndefinedProps } from '../../common/util/object' import { TipTxn } from '../../common/txn' import { Group, GROUP_CHAT_SLUG } from '../../common/group' import { Challenge } from '../../common/challenge' +import { richTextToString } from 'common/util/parse' const firestore = admin.firestore() type user_to_reason_texts = { @@ -155,17 +156,6 @@ export const createNotification = async ( } } - /** @deprecated parse from rich text instead */ - const parseMentions = async (source: string) => { - const mentions = source.match(/@\w+/g) - if (!mentions) return [] - return Promise.all( - mentions.map( - async (username) => (await getUserByUsername(username.slice(1)))?.id - ) - ) - } - const notifyTaggedUsers = ( userToReasonTexts: user_to_reason_texts, userIds: (string | undefined)[] @@ -301,8 +291,7 @@ export const createNotification = async ( if (sourceType === 'comment') { if (recipients?.[0] && relatedSourceType) notifyRepliedUser(userToReasonTexts, recipients[0], relatedSourceType) - if (sourceText) - notifyTaggedUsers(userToReasonTexts, await parseMentions(sourceText)) + if (sourceText) notifyTaggedUsers(userToReasonTexts, recipients ?? []) } await notifyContractCreator(userToReasonTexts, sourceContract) await notifyOtherAnswerersOnContract(userToReasonTexts, sourceContract) @@ -427,7 +416,7 @@ export const createGroupCommentNotification = async ( sourceUserName: fromUser.name, sourceUserUsername: fromUser.username, sourceUserAvatarUrl: fromUser.avatarUrl, - sourceText: comment.text, + sourceText: richTextToString(comment.content), sourceSlug, sourceTitle: `${group.name}`, isSeenOnHref: sourceSlug, diff --git a/functions/src/emails.ts b/functions/src/emails.ts index b7469e9f..d594ae65 100644 --- a/functions/src/emails.ts +++ b/functions/src/emails.ts @@ -17,6 +17,7 @@ import { formatNumericProbability } from '../../common/pseudo-numeric' import { sendTemplateEmail } from './send-email' import { getPrivateUser, getUser } from './utils' import { getFunctionUrl } from '../../common/api' +import { richTextToString } from 'common/util/parse' const UNSUBSCRIBE_ENDPOINT = getFunctionUrl('unsubscribe') @@ -291,7 +292,8 @@ export const sendNewCommentEmail = async ( const unsubscribeUrl = `${UNSUBSCRIBE_ENDPOINT}?id=${userId}&type=${emailType}` const { name: commentorName, avatarUrl: commentorAvatarUrl } = commentCreator - const { text } = comment + const { content } = comment + const text = richTextToString(content) let betDescription = '' if (bet) { diff --git a/functions/src/on-create-comment-on-contract.ts b/functions/src/on-create-comment-on-contract.ts index 4719fd08..a8bc567e 100644 --- a/functions/src/on-create-comment-on-contract.ts +++ b/functions/src/on-create-comment-on-contract.ts @@ -1,13 +1,13 @@ import * as functions from 'firebase-functions' import * as admin from 'firebase-admin' -import { uniq } from 'lodash' - +import { compact, uniq } from 'lodash' import { getContract, getUser, getValues } from './utils' import { Comment } from '../../common/comment' import { sendNewCommentEmail } from './emails' import { Bet } from '../../common/bet' import { Answer } from '../../common/answer' import { createNotification } from './create-notification' +import { parseMentions, richTextToString } from 'common/util/parse' const firestore = admin.firestore() @@ -71,7 +71,10 @@ export const onCreateCommentOnContract = functions const repliedUserId = comment.replyToCommentId ? comments.find((c) => c.id === comment.replyToCommentId)?.userId : answer?.userId - const recipients = repliedUserId ? [repliedUserId] : [] + + const recipients = uniq( + compact([...parseMentions(comment.content), repliedUserId]) + ) await createNotification( comment.id, @@ -79,7 +82,7 @@ export const onCreateCommentOnContract = functions 'created', commentCreator, eventId, - comment.text, + richTextToString(comment.content), { contract, relatedSourceType, recipients } ) diff --git a/web/components/comments-list.tsx b/web/components/comments-list.tsx index f8e1d7e1..2a467f6d 100644 --- a/web/components/comments-list.tsx +++ b/web/components/comments-list.tsx @@ -8,8 +8,8 @@ import { RelativeTimestamp } from './relative-timestamp' import { UserLink } from './user-page' import { User } from 'common/user' import { Col } from './layout/col' -import { Linkify } from './linkify' import { groupBy } from 'lodash' +import { Content } from './editor' export function UserCommentsList(props: { user: User @@ -50,7 +50,8 @@ export function UserCommentsList(props: { function ProfileComment(props: { comment: Comment; className?: string }) { const { comment, className } = props - const { text, userUsername, userName, userAvatarUrl, createdTime } = comment + const { text, content, userUsername, userName, userAvatarUrl, createdTime } = + comment // TODO: find and attach relevant bets by comment betId at some point return ( <Row className={className}> @@ -64,7 +65,7 @@ function ProfileComment(props: { comment: Comment; className?: string }) { />{' '} <RelativeTimestamp time={createdTime} /> </p> - <Linkify text={text} /> + <Content content={content || text} /> </div> </Row> ) diff --git a/web/components/contract/contract-leaderboard.tsx b/web/components/contract/contract-leaderboard.tsx index deb9b857..6f1a778d 100644 --- a/web/components/contract/contract-leaderboard.tsx +++ b/web/components/contract/contract-leaderboard.tsx @@ -107,7 +107,6 @@ export function ContractTopTrades(props: { comment={commentsById[topCommentId]} tips={tips[topCommentId]} betsBySameUser={[betsById[topCommentId]]} - truncate={false} smallAvatar={false} /> </div> diff --git a/web/components/editor.tsx b/web/components/editor.tsx index 963cea7e..f71e8589 100644 --- a/web/components/editor.tsx +++ b/web/components/editor.tsx @@ -41,14 +41,16 @@ export function useTextEditor(props: { max?: number defaultValue?: Content disabled?: boolean + simple?: boolean }) { - const { placeholder, max, defaultValue = '', disabled } = props + const { placeholder, max, defaultValue = '', disabled, simple } = props const users = useUsers() const editorClass = clsx( proseClass, - 'min-h-[6em] resize-none outline-none border-none pt-3 px-4 focus:ring-0' + !simple && 'min-h-[6em]', + 'outline-none pt-2 px-4' ) const editor = useEditor( @@ -56,7 +58,8 @@ export function useTextEditor(props: { editorProps: { attributes: { class: editorClass } }, extensions: [ StarterKit.configure({ - heading: { levels: [1, 2, 3] }, + heading: simple ? false : { levels: [1, 2, 3] }, + horizontalRule: simple ? false : {}, }), Placeholder.configure({ placeholder, @@ -120,8 +123,9 @@ function isValidIframe(text: string) { export function TextEditor(props: { editor: Editor | null upload: ReturnType<typeof useUploadMutation> + children?: React.ReactNode // additional toolbar buttons }) { - const { editor, upload } = props + const { editor, upload, children } = props const [iframeOpen, setIframeOpen] = useState(false) return ( @@ -143,20 +147,10 @@ export function TextEditor(props: { images! </FloatingMenu> )} - <div className="overflow-hidden rounded-lg border border-gray-300 shadow-sm focus-within:border-indigo-500 focus-within:ring-1 focus-within:ring-indigo-500"> + <div className="rounded-lg border border-gray-300 shadow-sm focus-within:border-indigo-500 focus-within:ring-1 focus-within:ring-indigo-500"> <EditorContent editor={editor} /> - {/* Spacer element to match the height of the toolbar */} - <div className="py-2" aria-hidden="true"> - {/* Matches height of button in toolbar (1px border + 36px content height) */} - <div className="py-px"> - <div className="h-9" /> - </div> - </div> - </div> - - {/* Toolbar, with buttons for image and embeds */} - <div className="absolute inset-x-0 bottom-0 flex justify-between py-2 pl-3 pr-2"> - <div className="flex items-center space-x-5"> + {/* Toolbar, with buttons for images and embeds */} + <div className="flex h-9 items-center gap-5 pl-4 pr-1"> <div className="flex items-center"> <FileUploadButton onFiles={upload.mutate} @@ -181,6 +175,8 @@ export function TextEditor(props: { <span className="sr-only">Embed an iframe</span> </button> </div> + <div className="ml-auto" /> + {children} </div> </div> </div> diff --git a/web/components/feed/feed-answer-comment-group.tsx b/web/components/feed/feed-answer-comment-group.tsx index aabb1081..edaf1fe5 100644 --- a/web/components/feed/feed-answer-comment-group.tsx +++ b/web/components/feed/feed-answer-comment-group.tsx @@ -31,9 +31,9 @@ export function FeedAnswerCommentGroup(props: { const { answer, contract, comments, tips, bets, user } = props const { username, avatarUrl, name, text } = answer - const [replyToUsername, setReplyToUsername] = useState('') + const [replyToUser, setReplyToUser] = + useState<Pick<User, 'id' | 'username'>>() const [showReply, setShowReply] = useState(false) - const [inputRef, setInputRef] = useState<HTMLTextAreaElement | null>(null) const [highlighted, setHighlighted] = useState(false) const router = useRouter() @@ -70,9 +70,14 @@ export function FeedAnswerCommentGroup(props: { const scrollAndOpenReplyInput = useEvent( (comment?: Comment, answer?: Answer) => { - setReplyToUsername(comment?.userUsername ?? answer?.username ?? '') + setReplyToUser( + comment + ? { id: comment.userId, username: comment.userUsername } + : answer + ? { id: answer.userId, username: answer.username } + : undefined + ) setShowReply(true) - inputRef?.focus() } ) @@ -80,7 +85,7 @@ export function FeedAnswerCommentGroup(props: { // Only show one comment input for a bet at a time if ( betsByCurrentUser.length > 1 && - inputRef?.textContent?.length === 0 && + // inputRef?.textContent?.length === 0 && //TODO: editor.isEmpty betsByCurrentUser.sort((a, b) => b.createdTime - a.createdTime)[0] ?.outcome !== answer.number.toString() ) @@ -89,10 +94,6 @@ export function FeedAnswerCommentGroup(props: { // eslint-disable-next-line react-hooks/exhaustive-deps }, [betsByCurrentUser.length, user, answer.number]) - useEffect(() => { - if (showReply && inputRef) inputRef.focus() - }, [inputRef, showReply]) - useEffect(() => { if (router.asPath.endsWith(`#${answerElementId}`)) { setHighlighted(true) @@ -154,7 +155,6 @@ export function FeedAnswerCommentGroup(props: { commentsList={commentsList} betsByUserId={betsByUserId} smallAvatar={true} - truncate={false} bets={bets} tips={tips} scrollAndOpenReplyInput={scrollAndOpenReplyInput} @@ -172,12 +172,8 @@ export function FeedAnswerCommentGroup(props: { betsByCurrentUser={betsByCurrentUser} commentsByCurrentUser={commentsByCurrentUser} parentAnswerOutcome={answer.number.toString()} - replyToUsername={replyToUsername} - setRef={setInputRef} - onSubmitComment={() => { - setShowReply(false) - setReplyToUsername('') - }} + replyToUser={replyToUser} + onSubmitComment={() => setShowReply(false)} /> </div> )} diff --git a/web/components/feed/feed-comments.tsx b/web/components/feed/feed-comments.tsx index f4c6eb74..fd2dbde2 100644 --- a/web/components/feed/feed-comments.tsx +++ b/web/components/feed/feed-comments.tsx @@ -13,25 +13,22 @@ import { Avatar } from 'web/components/avatar' import { UserLink } from 'web/components/user-page' import { OutcomeLabel } from 'web/components/outcome-label' import { CopyLinkDateTimeComponent } from 'web/components/feed/copy-link-date-time' -import { contractPath } from 'web/lib/firebase/contracts' import { firebaseLogin } from 'web/lib/firebase/users' import { createCommentOnContract, MAX_COMMENT_LENGTH, } from 'web/lib/firebase/comments' -import Textarea from 'react-expanding-textarea' -import { Linkify } from 'web/components/linkify' -import { SiteLink } from 'web/components/site-link' import { BetStatusText } from 'web/components/feed/feed-bets' import { Col } from 'web/components/layout/col' import { getProbability } from 'common/calculate' import { LoadingIndicator } from 'web/components/loading-indicator' import { PaperAirplaneIcon } from '@heroicons/react/outline' import { track } from 'web/lib/service/analytics' -import { useEvent } from 'web/hooks/use-event' import { Tipper } from '../tipper' import { CommentTipMap, CommentTips } from 'web/hooks/use-tip-txns' import { useWindowSize } from 'web/hooks/use-window-size' +import { Content, TextEditor, useTextEditor } from '../editor' +import { Editor } from '@tiptap/react' export function FeedCommentThread(props: { contract: Contract @@ -39,20 +36,12 @@ export function FeedCommentThread(props: { tips: CommentTipMap parentComment: Comment bets: Bet[] - truncate?: boolean smallAvatar?: boolean }) { - const { - contract, - comments, - bets, - tips, - truncate, - smallAvatar, - parentComment, - } = props + const { contract, comments, bets, tips, smallAvatar, parentComment } = props const [showReply, setShowReply] = useState(false) - const [replyToUsername, setReplyToUsername] = useState('') + const [replyToUser, setReplyToUser] = + useState<{ id: string; username: string }>() const betsByUserId = groupBy(bets, (bet) => bet.userId) const user = useUser() const commentsList = comments.filter( @@ -60,15 +49,12 @@ export function FeedCommentThread(props: { parentComment.id && comment.replyToCommentId === parentComment.id ) commentsList.unshift(parentComment) - const [inputRef, setInputRef] = useState<HTMLTextAreaElement | null>(null) + function scrollAndOpenReplyInput(comment: Comment) { - setReplyToUsername(comment.userUsername) + setReplyToUser({ id: comment.userId, username: comment.userUsername }) setShowReply(true) - inputRef?.focus() } - useEffect(() => { - if (showReply && inputRef) inputRef.focus() - }, [inputRef, showReply]) + return ( <Col className={'w-full gap-3 pr-1'}> <span @@ -81,7 +67,6 @@ export function FeedCommentThread(props: { betsByUserId={betsByUserId} tips={tips} smallAvatar={smallAvatar} - truncate={truncate} bets={bets} scrollAndOpenReplyInput={scrollAndOpenReplyInput} /> @@ -98,13 +83,9 @@ export function FeedCommentThread(props: { (c) => c.userId === user?.id )} parentCommentId={parentComment.id} - replyToUsername={replyToUsername} + replyToUser={replyToUser} parentAnswerOutcome={comments[0].answerOutcome} - setRef={setInputRef} - onSubmitComment={() => { - setShowReply(false) - setReplyToUsername('') - }} + onSubmitComment={() => setShowReply(false)} /> </Col> )} @@ -121,14 +102,12 @@ export function CommentRepliesList(props: { bets: Bet[] treatFirstIndexEqually?: boolean smallAvatar?: boolean - truncate?: boolean }) { const { contract, commentsList, betsByUserId, tips, - truncate, smallAvatar, bets, scrollAndOpenReplyInput, @@ -168,7 +147,6 @@ export function CommentRepliesList(props: { : undefined } smallAvatar={smallAvatar} - truncate={truncate} /> </div> ))} @@ -182,7 +160,6 @@ export function FeedComment(props: { tips: CommentTips betsBySameUser: Bet[] probAtCreatedTime?: number - truncate?: boolean smallAvatar?: boolean onReplyClick?: (comment: Comment) => void }) { @@ -192,10 +169,10 @@ export function FeedComment(props: { tips, betsBySameUser, probAtCreatedTime, - truncate, onReplyClick, } = props - const { text, userUsername, userName, userAvatarUrl, createdTime } = comment + const { text, content, userUsername, userName, userAvatarUrl, createdTime } = + comment let betOutcome: string | undefined, bought: string | undefined, money: string | undefined @@ -276,11 +253,9 @@ export function FeedComment(props: { elementId={comment.id} /> </div> - <TruncatedComment - comment={text} - moreHref={contractPath(contract)} - shouldTruncate={truncate} - /> + <div className="mt-2 text-[15px] text-gray-700"> + <Content content={content || text} /> + </div> <Row className="mt-2 items-center gap-6 text-xs text-gray-500"> <Tipper comment={comment} tips={tips ?? {}} /> {onReplyClick && ( @@ -345,8 +320,7 @@ export function CommentInput(props: { contract: Contract betsByCurrentUser: Bet[] commentsByCurrentUser: Comment[] - replyToUsername?: string - setRef?: (ref: HTMLTextAreaElement) => void + replyToUser?: { id: string; username: string } // Reply to a free response answer parentAnswerOutcome?: string // Reply to another comment @@ -359,12 +333,18 @@ export function CommentInput(props: { commentsByCurrentUser, parentAnswerOutcome, parentCommentId, - replyToUsername, + replyToUser, onSubmitComment, - setRef, } = props const user = useUser() - const [comment, setComment] = useState('') + const { editor, upload } = useTextEditor({ + simple: true, + max: MAX_COMMENT_LENGTH, + placeholder: + !!parentCommentId || !!parentAnswerOutcome + ? 'Write a reply...' + : 'Write a comment...', + }) const [isSubmitting, setIsSubmitting] = useState(false) const mostRecentCommentableBet = getMostRecentCommentableBet( @@ -380,18 +360,17 @@ export function CommentInput(props: { track('sign in to comment') return await firebaseLogin() } - if (!comment || isSubmitting) return + if (!editor || editor.isEmpty || isSubmitting) return setIsSubmitting(true) await createCommentOnContract( contract.id, - comment, + editor.getJSON(), user, betId, parentAnswerOutcome, parentCommentId ) onSubmitComment?.() - setComment('') setIsSubmitting(false) } @@ -446,14 +425,12 @@ export function CommentInput(props: { )} </div> <CommentInputTextArea - commentText={comment} - setComment={setComment} - isReply={!!parentCommentId || !!parentAnswerOutcome} - replyToUsername={replyToUsername ?? ''} + editor={editor} + upload={upload} + replyToUser={replyToUser} user={user} submitComment={submitComment} isSubmitting={isSubmitting} - setRef={setRef} presetId={id} /> </div> @@ -465,94 +442,89 @@ export function CommentInput(props: { export function CommentInputTextArea(props: { user: User | undefined | null - isReply: boolean - replyToUsername: string - commentText: string - setComment: (text: string) => void + replyToUser?: { id: string; username: string } + editor: Editor | null + upload: Parameters<typeof TextEditor>[0]['upload'] submitComment: (id?: string) => void isSubmitting: boolean - setRef?: (ref: HTMLTextAreaElement) => void + submitOnEnter?: boolean presetId?: string - enterToSubmitOnDesktop?: boolean }) { const { - isReply, - setRef, user, - commentText, - setComment, + editor, + upload, submitComment, presetId, isSubmitting, - replyToUsername, - enterToSubmitOnDesktop, + submitOnEnter, + replyToUser, } = props - const { width } = useWindowSize() - const memoizedSetComment = useEvent(setComment) + const isMobile = (useWindowSize().width ?? 0) < 768 // TODO: base off input device (keybord vs touch) + useEffect(() => { - if (!replyToUsername || !user || replyToUsername === user.username) return - const replacement = `@${replyToUsername} ` - memoizedSetComment(replacement + commentText.replace(replacement, '')) + editor?.setEditable(!isSubmitting) + }, [isSubmitting, editor]) + + const submit = () => { + submitComment(presetId) + editor?.commands?.clearContent() + } + + useEffect(() => { + if (!editor) { + return + } + // submit on Enter key + editor.setOptions({ + editorProps: { + handleKeyDown: (view, event) => { + if ( + submitOnEnter && + event.key === 'Enter' && + !event.shiftKey && + (!isMobile || event.ctrlKey || event.metaKey) && + // mention list is closed + !(view.state as any).mention$.active + ) { + submit() + event.preventDefault() + return true + } + return false + }, + }, + }) + // insert at mention + if (replyToUser) { + editor.commands.insertContentAt(0, { + type: 'mention', + attrs: { label: replyToUser.username, id: replyToUser.id }, + }) + editor.commands.focus() + } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [user, replyToUsername, memoizedSetComment]) + }, [editor]) + return ( <> - <Row className="gap-1.5 text-gray-700"> - <Textarea - ref={setRef} - value={commentText} - onChange={(e) => setComment(e.target.value)} - className={clsx('textarea textarea-bordered w-full resize-none')} - // Make room for floating submit button. - style={{ paddingRight: 48 }} - placeholder={ - isReply - ? 'Write a reply... ' - : enterToSubmitOnDesktop - ? 'Send a message' - : 'Write a comment...' - } - autoFocus={false} - maxLength={MAX_COMMENT_LENGTH} - disabled={isSubmitting} - onKeyDown={(e) => { - if ( - (enterToSubmitOnDesktop && - e.key === 'Enter' && - !e.shiftKey && - width && - width > 768) || - (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) - ) { - e.preventDefault() - submitComment(presetId) - e.currentTarget.blur() - } - }} - /> - - <Col className={clsx('relative justify-end')}> + <div> + <TextEditor editor={editor} upload={upload}> {user && !isSubmitting && ( <button - className={clsx( - 'btn btn-ghost btn-sm absolute right-2 bottom-2 flex-row pl-2 capitalize', - !commentText && 'pointer-events-none text-gray-500' - )} - onClick={() => { - submitComment(presetId) - }} + className="btn btn-ghost btn-sm px-2 disabled:bg-inherit disabled:text-gray-300" + disabled={!editor || editor.isEmpty} + onClick={submit} > - <PaperAirplaneIcon - className={'m-0 min-w-[22px] rotate-90 p-0 '} - height={25} - /> + <PaperAirplaneIcon className="m-0 h-[25px] min-w-[22px] rotate-90 p-0" /> </button> )} + {isSubmitting && ( <LoadingIndicator spinnerClassName={'border-gray-500'} /> )} - </Col> - </Row> + </TextEditor> + </div> <Row> {!user && ( <button @@ -567,38 +539,6 @@ export function CommentInputTextArea(props: { ) } -export function TruncatedComment(props: { - comment: string - moreHref: string - shouldTruncate?: boolean -}) { - const { comment, moreHref, shouldTruncate } = props - let truncated = comment - - // Keep descriptions to at most 400 characters - const MAX_CHARS = 400 - if (shouldTruncate && truncated.length > MAX_CHARS) { - truncated = truncated.slice(0, MAX_CHARS) - // Make sure to end on a space - const i = truncated.lastIndexOf(' ') - truncated = truncated.slice(0, i) - } - - return ( - <div - className="mt-2 whitespace-pre-line break-words text-gray-700" - style={{ fontSize: 15 }} - > - <Linkify text={truncated} /> - {truncated != comment && ( - <SiteLink href={moreHref} className="text-indigo-700"> - ... (show more) - </SiteLink> - )} - </div> - ) -} - function getBettorsLargestPositionBeforeTime( contract: Contract, createdTime: number, diff --git a/web/components/groups/group-chat.tsx b/web/components/groups/group-chat.tsx index 91de63c6..db7e558b 100644 --- a/web/components/groups/group-chat.tsx +++ b/web/components/groups/group-chat.tsx @@ -5,24 +5,19 @@ import React, { useEffect, memo, useState, useMemo } from 'react' import { Avatar } from 'web/components/avatar' import { Group } from 'common/group' import { Comment, createCommentOnGroup } from 'web/lib/firebase/comments' -import { - CommentInputTextArea, - TruncatedComment, -} from 'web/components/feed/feed-comments' +import { CommentInputTextArea } from 'web/components/feed/feed-comments' import { track } from 'web/lib/service/analytics' import { firebaseLogin } from 'web/lib/firebase/users' - import { useRouter } from 'next/router' import clsx from 'clsx' import { UserLink } from 'web/components/user-page' - -import { groupPath } from 'web/lib/firebase/groups' import { CopyLinkDateTimeComponent } from 'web/components/feed/copy-link-date-time' import { CommentTipMap, CommentTips } from 'web/hooks/use-tip-txns' import { Tipper } from 'web/components/tipper' import { sum } from 'lodash' import { formatMoney } from 'common/util/format' import { useWindowSize } from 'web/hooks/use-window-size' +import { Content, useTextEditor } from 'web/components/editor' import { useUnseenPreferredNotifications } from 'web/hooks/use-notifications' import { ChatIcon, ChevronDownIcon } from '@heroicons/react/outline' import { setNotificationsAsSeen } from 'web/pages/notifications' @@ -34,16 +29,18 @@ export function GroupChat(props: { tips: CommentTipMap }) { const { messages, user, group, tips } = props - const [messageText, setMessageText] = useState('') + const { editor, upload } = useTextEditor({ + simple: true, + placeholder: 'Send a message', + }) const [isSubmitting, setIsSubmitting] = useState(false) const [scrollToBottomRef, setScrollToBottomRef] = useState<HTMLDivElement | null>(null) const [scrollToMessageId, setScrollToMessageId] = useState('') const [scrollToMessageRef, setScrollToMessageRef] = useState<HTMLDivElement | null>(null) - const [replyToUsername, setReplyToUsername] = useState('') - const [inputRef, setInputRef] = useState<HTMLTextAreaElement | null>(null) - const [groupedMessages, setGroupedMessages] = useState<Comment[]>([]) + const [replyToUser, setReplyToUser] = useState<any>() + const router = useRouter() const isMember = user && group.memberIds.includes(user?.id) @@ -54,25 +51,26 @@ export function GroupChat(props: { const remainingHeight = (height ?? 0) - (containerRef?.offsetTop ?? 0) - bottomBarHeight - useMemo(() => { + // array of groups, where each group is an array of messages that are displayed as one + const groupedMessages = useMemo(() => { // Group messages with createdTime within 2 minutes of each other. - const tempMessages = [] + const tempGrouped: Comment[][] = [] for (let i = 0; i < messages.length; i++) { const message = messages[i] - if (i === 0) tempMessages.push({ ...message }) + if (i === 0) tempGrouped.push([message]) else { const prevMessage = messages[i - 1] const diff = message.createdTime - prevMessage.createdTime const creatorsMatch = message.userId === prevMessage.userId if (diff < 2 * 60 * 1000 && creatorsMatch) { - tempMessages[tempMessages.length - 1].text += `\n${message.text}` + tempGrouped.at(-1)?.push(message) } else { - tempMessages.push({ ...message }) + tempGrouped.push([message]) } } } - setGroupedMessages(tempMessages) + return tempGrouped }, [messages]) useEffect(() => { @@ -94,11 +92,12 @@ export function GroupChat(props: { useEffect(() => { // is mobile? - if (inputRef && width && width > 720) inputRef.focus() - }, [inputRef, width]) + if (width && width > 720) focusInput() + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [width]) function onReplyClick(comment: Comment) { - setReplyToUsername(comment.userUsername) + setReplyToUser({ id: comment.userId, username: comment.userUsername }) } async function submitMessage() { @@ -106,13 +105,16 @@ export function GroupChat(props: { track('sign in to comment') return await firebaseLogin() } - if (!messageText || isSubmitting) return + if (!editor || editor.isEmpty || isSubmitting) return setIsSubmitting(true) - await createCommentOnGroup(group.id, messageText, user) - setMessageText('') + await createCommentOnGroup(group.id, editor.getJSON(), user) + editor.commands.clearContent() setIsSubmitting(false) - setReplyToUsername('') - inputRef?.focus() + setReplyToUser(undefined) + focusInput() + } + function focusInput() { + editor?.commands.focus() } return ( @@ -123,20 +125,20 @@ export function GroupChat(props: { } ref={setScrollToBottomRef} > - {groupedMessages.map((message) => ( + {groupedMessages.map((messages) => ( <GroupMessage user={user} - key={message.id} - comment={message} + key={`group ${messages[0].id}`} + comments={messages} group={group} onReplyClick={onReplyClick} - highlight={message.id === scrollToMessageId} + highlight={messages[0].id === scrollToMessageId} setRef={ - scrollToMessageId === message.id + scrollToMessageId === messages[0].id ? setScrollToMessageRef : undefined } - tips={tips[message.id] ?? {}} + tips={tips[messages[0].id] ?? {}} /> ))} {messages.length === 0 && ( @@ -144,7 +146,7 @@ export function GroupChat(props: { No messages yet. Why not{isMember ? ` ` : ' join and '} <button className={'cursor-pointer font-bold text-gray-700'} - onClick={() => inputRef?.focus()} + onClick={focusInput} > add one? </button> @@ -162,15 +164,13 @@ export function GroupChat(props: { </div> <div className={'flex-1'}> <CommentInputTextArea - commentText={messageText} - setComment={setMessageText} - isReply={false} + editor={editor} + upload={upload} user={user} - replyToUsername={replyToUsername} + replyToUser={replyToUser} submitComment={submitMessage} isSubmitting={isSubmitting} - enterToSubmitOnDesktop={true} - setRef={setInputRef} + submitOnEnter /> </div> </div> @@ -292,16 +292,18 @@ function GroupChatNotificationsIcon(props: { const GroupMessage = memo(function GroupMessage_(props: { user: User | null | undefined - comment: Comment + comments: Comment[] group: Group onReplyClick?: (comment: Comment) => void setRef?: (ref: HTMLDivElement) => void highlight?: boolean tips: CommentTips }) { - const { comment, onReplyClick, group, setRef, highlight, user, tips } = props - const { text, userUsername, userName, userAvatarUrl, createdTime } = comment - const isCreatorsComment = user && comment.userId === user.id + const { comments, onReplyClick, group, setRef, highlight, user, tips } = props + const first = comments[0] + const { id, userUsername, userName, userAvatarUrl, createdTime } = first + + const isCreatorsComment = user && first.userId === user.id return ( <Col ref={setRef} @@ -331,23 +333,21 @@ const GroupMessage = memo(function GroupMessage_(props: { prefix={'group'} slug={group.slug} createdTime={createdTime} - elementId={comment.id} - /> - </Row> - <Row className={'text-black'}> - <TruncatedComment - comment={text} - moreHref={groupPath(group.slug)} - shouldTruncate={false} + elementId={id} /> </Row> + <div className="mt-2 text-black"> + {comments.map((comment) => ( + <Content content={comment.content || comment.text} /> + ))} + </div> <Row> {!isCreatorsComment && onReplyClick && ( <button className={ 'self-start py-1 text-xs font-bold text-gray-500 hover:underline' } - onClick={() => onReplyClick(comment)} + onClick={() => onReplyClick(first)} > Reply </button> @@ -357,7 +357,7 @@ const GroupMessage = memo(function GroupMessage_(props: { {formatMoney(sum(Object.values(tips)))} </span> )} - {!isCreatorsComment && <Tipper comment={comment} tips={tips} />} + {!isCreatorsComment && <Tipper comment={first} tips={tips} />} </Row> </Col> ) diff --git a/web/lib/firebase/comments.ts b/web/lib/firebase/comments.ts index 5775a2bb..e82c6d45 100644 --- a/web/lib/firebase/comments.ts +++ b/web/lib/firebase/comments.ts @@ -14,6 +14,7 @@ import { User } from 'common/user' import { Comment } from 'common/comment' import { removeUndefinedProps } from 'common/util/object' import { track } from '@amplitude/analytics-browser' +import { JSONContent } from '@tiptap/react' export type { Comment } @@ -21,7 +22,7 @@ export const MAX_COMMENT_LENGTH = 10000 export async function createCommentOnContract( contractId: string, - text: string, + content: JSONContent, commenter: User, betId?: string, answerOutcome?: string, @@ -34,7 +35,7 @@ export async function createCommentOnContract( id: ref.id, contractId, userId: commenter.id, - text: text.slice(0, MAX_COMMENT_LENGTH), + content: content, createdTime: Date.now(), userName: commenter.name, userUsername: commenter.username, @@ -53,7 +54,7 @@ export async function createCommentOnContract( } export async function createCommentOnGroup( groupId: string, - text: string, + content: JSONContent, user: User, replyToCommentId?: string ) { @@ -62,7 +63,7 @@ export async function createCommentOnGroup( id: ref.id, groupId, userId: user.id, - text: text.slice(0, MAX_COMMENT_LENGTH), + content: content, createdTime: Date.now(), userName: user.name, userUsername: user.username, diff --git a/web/pages/[username]/[contractSlug].tsx b/web/pages/[username]/[contractSlug].tsx index 0da6c994..5866f899 100644 --- a/web/pages/[username]/[contractSlug].tsx +++ b/web/pages/[username]/[contractSlug].tsx @@ -354,7 +354,6 @@ function ContractTopTrades(props: { comment={commentsById[topCommentId]} tips={tips[topCommentId]} betsBySameUser={[betsById[topCommentId]]} - truncate={false} smallAvatar={false} /> </div> From 33906adfe489cbc3489bc014c4a3253d96660ec0 Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Thu, 4 Aug 2022 16:49:59 -0700 Subject: [PATCH 45/83] Revert "Switch comments/chat to rich text editor (#703)" This reverts commit f52da72115bfacb0af5a4d54c137a936b33d9eee. --- common/comment.ts | 6 +- functions/src/create-notification.ts | 19 +- functions/src/emails.ts | 4 +- .../src/on-create-comment-on-contract.ts | 11 +- web/components/comments-list.tsx | 7 +- .../contract/contract-leaderboard.tsx | 1 + web/components/editor.tsx | 30 ++- .../feed/feed-answer-comment-group.tsx | 28 +- web/components/feed/feed-comments.tsx | 242 +++++++++++------- web/components/groups/group-chat.tsx | 104 ++++---- web/lib/firebase/comments.ts | 9 +- web/pages/[username]/[contractSlug].tsx | 1 + 12 files changed, 266 insertions(+), 196 deletions(-) diff --git a/common/comment.ts b/common/comment.ts index a217b292..0d0c4daf 100644 --- a/common/comment.ts +++ b/common/comment.ts @@ -1,5 +1,3 @@ -import type { JSONContent } from '@tiptap/core' - // Currently, comments are created after the bet, not atomically with the bet. // They're uniquely identified by the pair contractId/betId. export type Comment = { @@ -11,9 +9,7 @@ export type Comment = { replyToCommentId?: string userId: string - /** @deprecated - content now stored as JSON in content*/ - text?: string - content: JSONContent + text: string createdTime: number // Denormalized, for rendering comments diff --git a/functions/src/create-notification.ts b/functions/src/create-notification.ts index 6e312906..e16920f7 100644 --- a/functions/src/create-notification.ts +++ b/functions/src/create-notification.ts @@ -7,7 +7,7 @@ import { } from '../../common/notification' import { User } from '../../common/user' import { Contract } from '../../common/contract' -import { getValues } from './utils' +import { getUserByUsername, getValues } from './utils' import { Comment } from '../../common/comment' import { uniq } from 'lodash' import { Bet, LimitBet } from '../../common/bet' @@ -17,7 +17,6 @@ import { removeUndefinedProps } from '../../common/util/object' import { TipTxn } from '../../common/txn' import { Group, GROUP_CHAT_SLUG } from '../../common/group' import { Challenge } from '../../common/challenge' -import { richTextToString } from 'common/util/parse' const firestore = admin.firestore() type user_to_reason_texts = { @@ -156,6 +155,17 @@ export const createNotification = async ( } } + /** @deprecated parse from rich text instead */ + const parseMentions = async (source: string) => { + const mentions = source.match(/@\w+/g) + if (!mentions) return [] + return Promise.all( + mentions.map( + async (username) => (await getUserByUsername(username.slice(1)))?.id + ) + ) + } + const notifyTaggedUsers = ( userToReasonTexts: user_to_reason_texts, userIds: (string | undefined)[] @@ -291,7 +301,8 @@ export const createNotification = async ( if (sourceType === 'comment') { if (recipients?.[0] && relatedSourceType) notifyRepliedUser(userToReasonTexts, recipients[0], relatedSourceType) - if (sourceText) notifyTaggedUsers(userToReasonTexts, recipients ?? []) + if (sourceText) + notifyTaggedUsers(userToReasonTexts, await parseMentions(sourceText)) } await notifyContractCreator(userToReasonTexts, sourceContract) await notifyOtherAnswerersOnContract(userToReasonTexts, sourceContract) @@ -416,7 +427,7 @@ export const createGroupCommentNotification = async ( sourceUserName: fromUser.name, sourceUserUsername: fromUser.username, sourceUserAvatarUrl: fromUser.avatarUrl, - sourceText: richTextToString(comment.content), + sourceText: comment.text, sourceSlug, sourceTitle: `${group.name}`, isSeenOnHref: sourceSlug, diff --git a/functions/src/emails.ts b/functions/src/emails.ts index d594ae65..b7469e9f 100644 --- a/functions/src/emails.ts +++ b/functions/src/emails.ts @@ -17,7 +17,6 @@ import { formatNumericProbability } from '../../common/pseudo-numeric' import { sendTemplateEmail } from './send-email' import { getPrivateUser, getUser } from './utils' import { getFunctionUrl } from '../../common/api' -import { richTextToString } from 'common/util/parse' const UNSUBSCRIBE_ENDPOINT = getFunctionUrl('unsubscribe') @@ -292,8 +291,7 @@ export const sendNewCommentEmail = async ( const unsubscribeUrl = `${UNSUBSCRIBE_ENDPOINT}?id=${userId}&type=${emailType}` const { name: commentorName, avatarUrl: commentorAvatarUrl } = commentCreator - const { content } = comment - const text = richTextToString(content) + const { text } = comment let betDescription = '' if (bet) { diff --git a/functions/src/on-create-comment-on-contract.ts b/functions/src/on-create-comment-on-contract.ts index a8bc567e..4719fd08 100644 --- a/functions/src/on-create-comment-on-contract.ts +++ b/functions/src/on-create-comment-on-contract.ts @@ -1,13 +1,13 @@ import * as functions from 'firebase-functions' import * as admin from 'firebase-admin' -import { compact, uniq } from 'lodash' +import { uniq } from 'lodash' + import { getContract, getUser, getValues } from './utils' import { Comment } from '../../common/comment' import { sendNewCommentEmail } from './emails' import { Bet } from '../../common/bet' import { Answer } from '../../common/answer' import { createNotification } from './create-notification' -import { parseMentions, richTextToString } from 'common/util/parse' const firestore = admin.firestore() @@ -71,10 +71,7 @@ export const onCreateCommentOnContract = functions const repliedUserId = comment.replyToCommentId ? comments.find((c) => c.id === comment.replyToCommentId)?.userId : answer?.userId - - const recipients = uniq( - compact([...parseMentions(comment.content), repliedUserId]) - ) + const recipients = repliedUserId ? [repliedUserId] : [] await createNotification( comment.id, @@ -82,7 +79,7 @@ export const onCreateCommentOnContract = functions 'created', commentCreator, eventId, - richTextToString(comment.content), + comment.text, { contract, relatedSourceType, recipients } ) diff --git a/web/components/comments-list.tsx b/web/components/comments-list.tsx index 2a467f6d..f8e1d7e1 100644 --- a/web/components/comments-list.tsx +++ b/web/components/comments-list.tsx @@ -8,8 +8,8 @@ import { RelativeTimestamp } from './relative-timestamp' import { UserLink } from './user-page' import { User } from 'common/user' import { Col } from './layout/col' +import { Linkify } from './linkify' import { groupBy } from 'lodash' -import { Content } from './editor' export function UserCommentsList(props: { user: User @@ -50,8 +50,7 @@ export function UserCommentsList(props: { function ProfileComment(props: { comment: Comment; className?: string }) { const { comment, className } = props - const { text, content, userUsername, userName, userAvatarUrl, createdTime } = - comment + const { text, userUsername, userName, userAvatarUrl, createdTime } = comment // TODO: find and attach relevant bets by comment betId at some point return ( <Row className={className}> @@ -65,7 +64,7 @@ function ProfileComment(props: { comment: Comment; className?: string }) { />{' '} <RelativeTimestamp time={createdTime} /> </p> - <Content content={content || text} /> + <Linkify text={text} /> </div> </Row> ) diff --git a/web/components/contract/contract-leaderboard.tsx b/web/components/contract/contract-leaderboard.tsx index 6f1a778d..deb9b857 100644 --- a/web/components/contract/contract-leaderboard.tsx +++ b/web/components/contract/contract-leaderboard.tsx @@ -107,6 +107,7 @@ export function ContractTopTrades(props: { comment={commentsById[topCommentId]} tips={tips[topCommentId]} betsBySameUser={[betsById[topCommentId]]} + truncate={false} smallAvatar={false} /> </div> diff --git a/web/components/editor.tsx b/web/components/editor.tsx index f71e8589..963cea7e 100644 --- a/web/components/editor.tsx +++ b/web/components/editor.tsx @@ -41,16 +41,14 @@ export function useTextEditor(props: { max?: number defaultValue?: Content disabled?: boolean - simple?: boolean }) { - const { placeholder, max, defaultValue = '', disabled, simple } = props + const { placeholder, max, defaultValue = '', disabled } = props const users = useUsers() const editorClass = clsx( proseClass, - !simple && 'min-h-[6em]', - 'outline-none pt-2 px-4' + 'min-h-[6em] resize-none outline-none border-none pt-3 px-4 focus:ring-0' ) const editor = useEditor( @@ -58,8 +56,7 @@ export function useTextEditor(props: { editorProps: { attributes: { class: editorClass } }, extensions: [ StarterKit.configure({ - heading: simple ? false : { levels: [1, 2, 3] }, - horizontalRule: simple ? false : {}, + heading: { levels: [1, 2, 3] }, }), Placeholder.configure({ placeholder, @@ -123,9 +120,8 @@ function isValidIframe(text: string) { export function TextEditor(props: { editor: Editor | null upload: ReturnType<typeof useUploadMutation> - children?: React.ReactNode // additional toolbar buttons }) { - const { editor, upload, children } = props + const { editor, upload } = props const [iframeOpen, setIframeOpen] = useState(false) return ( @@ -147,10 +143,20 @@ export function TextEditor(props: { images! </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="overflow-hidden rounded-lg border border-gray-300 shadow-sm focus-within:border-indigo-500 focus-within:ring-1 focus-within:ring-indigo-500"> <EditorContent editor={editor} /> - {/* Toolbar, with buttons for images and embeds */} - <div className="flex h-9 items-center gap-5 pl-4 pr-1"> + {/* Spacer element to match the height of the toolbar */} + <div className="py-2" aria-hidden="true"> + {/* Matches height of button in toolbar (1px border + 36px content height) */} + <div className="py-px"> + <div className="h-9" /> + </div> + </div> + </div> + + {/* Toolbar, with buttons for image and embeds */} + <div className="absolute inset-x-0 bottom-0 flex justify-between py-2 pl-3 pr-2"> + <div className="flex items-center space-x-5"> <div className="flex items-center"> <FileUploadButton onFiles={upload.mutate} @@ -175,8 +181,6 @@ export function TextEditor(props: { <span className="sr-only">Embed an iframe</span> </button> </div> - <div className="ml-auto" /> - {children} </div> </div> </div> diff --git a/web/components/feed/feed-answer-comment-group.tsx b/web/components/feed/feed-answer-comment-group.tsx index edaf1fe5..aabb1081 100644 --- a/web/components/feed/feed-answer-comment-group.tsx +++ b/web/components/feed/feed-answer-comment-group.tsx @@ -31,9 +31,9 @@ export function FeedAnswerCommentGroup(props: { const { answer, contract, comments, tips, bets, user } = props const { username, avatarUrl, name, text } = answer - const [replyToUser, setReplyToUser] = - useState<Pick<User, 'id' | 'username'>>() + const [replyToUsername, setReplyToUsername] = useState('') const [showReply, setShowReply] = useState(false) + const [inputRef, setInputRef] = useState<HTMLTextAreaElement | null>(null) const [highlighted, setHighlighted] = useState(false) const router = useRouter() @@ -70,14 +70,9 @@ export function FeedAnswerCommentGroup(props: { const scrollAndOpenReplyInput = useEvent( (comment?: Comment, answer?: Answer) => { - setReplyToUser( - comment - ? { id: comment.userId, username: comment.userUsername } - : answer - ? { id: answer.userId, username: answer.username } - : undefined - ) + setReplyToUsername(comment?.userUsername ?? answer?.username ?? '') setShowReply(true) + inputRef?.focus() } ) @@ -85,7 +80,7 @@ export function FeedAnswerCommentGroup(props: { // Only show one comment input for a bet at a time if ( betsByCurrentUser.length > 1 && - // inputRef?.textContent?.length === 0 && //TODO: editor.isEmpty + inputRef?.textContent?.length === 0 && betsByCurrentUser.sort((a, b) => b.createdTime - a.createdTime)[0] ?.outcome !== answer.number.toString() ) @@ -94,6 +89,10 @@ export function FeedAnswerCommentGroup(props: { // eslint-disable-next-line react-hooks/exhaustive-deps }, [betsByCurrentUser.length, user, answer.number]) + useEffect(() => { + if (showReply && inputRef) inputRef.focus() + }, [inputRef, showReply]) + useEffect(() => { if (router.asPath.endsWith(`#${answerElementId}`)) { setHighlighted(true) @@ -155,6 +154,7 @@ export function FeedAnswerCommentGroup(props: { commentsList={commentsList} betsByUserId={betsByUserId} smallAvatar={true} + truncate={false} bets={bets} tips={tips} scrollAndOpenReplyInput={scrollAndOpenReplyInput} @@ -172,8 +172,12 @@ export function FeedAnswerCommentGroup(props: { betsByCurrentUser={betsByCurrentUser} commentsByCurrentUser={commentsByCurrentUser} parentAnswerOutcome={answer.number.toString()} - replyToUser={replyToUser} - onSubmitComment={() => setShowReply(false)} + replyToUsername={replyToUsername} + setRef={setInputRef} + onSubmitComment={() => { + setShowReply(false) + setReplyToUsername('') + }} /> </div> )} diff --git a/web/components/feed/feed-comments.tsx b/web/components/feed/feed-comments.tsx index fd2dbde2..f4c6eb74 100644 --- a/web/components/feed/feed-comments.tsx +++ b/web/components/feed/feed-comments.tsx @@ -13,22 +13,25 @@ import { Avatar } from 'web/components/avatar' import { UserLink } from 'web/components/user-page' import { OutcomeLabel } from 'web/components/outcome-label' import { CopyLinkDateTimeComponent } from 'web/components/feed/copy-link-date-time' +import { contractPath } from 'web/lib/firebase/contracts' import { firebaseLogin } from 'web/lib/firebase/users' import { createCommentOnContract, MAX_COMMENT_LENGTH, } from 'web/lib/firebase/comments' +import Textarea from 'react-expanding-textarea' +import { Linkify } from 'web/components/linkify' +import { SiteLink } from 'web/components/site-link' import { BetStatusText } from 'web/components/feed/feed-bets' import { Col } from 'web/components/layout/col' import { getProbability } from 'common/calculate' import { LoadingIndicator } from 'web/components/loading-indicator' import { PaperAirplaneIcon } from '@heroicons/react/outline' import { track } from 'web/lib/service/analytics' +import { useEvent } from 'web/hooks/use-event' import { Tipper } from '../tipper' import { CommentTipMap, CommentTips } from 'web/hooks/use-tip-txns' import { useWindowSize } from 'web/hooks/use-window-size' -import { Content, TextEditor, useTextEditor } from '../editor' -import { Editor } from '@tiptap/react' export function FeedCommentThread(props: { contract: Contract @@ -36,12 +39,20 @@ export function FeedCommentThread(props: { tips: CommentTipMap parentComment: Comment bets: Bet[] + truncate?: boolean smallAvatar?: boolean }) { - const { contract, comments, bets, tips, smallAvatar, parentComment } = props + const { + contract, + comments, + bets, + tips, + truncate, + smallAvatar, + parentComment, + } = props const [showReply, setShowReply] = useState(false) - const [replyToUser, setReplyToUser] = - useState<{ id: string; username: string }>() + const [replyToUsername, setReplyToUsername] = useState('') const betsByUserId = groupBy(bets, (bet) => bet.userId) const user = useUser() const commentsList = comments.filter( @@ -49,12 +60,15 @@ export function FeedCommentThread(props: { parentComment.id && comment.replyToCommentId === parentComment.id ) commentsList.unshift(parentComment) - + const [inputRef, setInputRef] = useState<HTMLTextAreaElement | null>(null) function scrollAndOpenReplyInput(comment: Comment) { - setReplyToUser({ id: comment.userId, username: comment.userUsername }) + setReplyToUsername(comment.userUsername) setShowReply(true) + inputRef?.focus() } - + useEffect(() => { + if (showReply && inputRef) inputRef.focus() + }, [inputRef, showReply]) return ( <Col className={'w-full gap-3 pr-1'}> <span @@ -67,6 +81,7 @@ export function FeedCommentThread(props: { betsByUserId={betsByUserId} tips={tips} smallAvatar={smallAvatar} + truncate={truncate} bets={bets} scrollAndOpenReplyInput={scrollAndOpenReplyInput} /> @@ -83,9 +98,13 @@ export function FeedCommentThread(props: { (c) => c.userId === user?.id )} parentCommentId={parentComment.id} - replyToUser={replyToUser} + replyToUsername={replyToUsername} parentAnswerOutcome={comments[0].answerOutcome} - onSubmitComment={() => setShowReply(false)} + setRef={setInputRef} + onSubmitComment={() => { + setShowReply(false) + setReplyToUsername('') + }} /> </Col> )} @@ -102,12 +121,14 @@ export function CommentRepliesList(props: { bets: Bet[] treatFirstIndexEqually?: boolean smallAvatar?: boolean + truncate?: boolean }) { const { contract, commentsList, betsByUserId, tips, + truncate, smallAvatar, bets, scrollAndOpenReplyInput, @@ -147,6 +168,7 @@ export function CommentRepliesList(props: { : undefined } smallAvatar={smallAvatar} + truncate={truncate} /> </div> ))} @@ -160,6 +182,7 @@ export function FeedComment(props: { tips: CommentTips betsBySameUser: Bet[] probAtCreatedTime?: number + truncate?: boolean smallAvatar?: boolean onReplyClick?: (comment: Comment) => void }) { @@ -169,10 +192,10 @@ export function FeedComment(props: { tips, betsBySameUser, probAtCreatedTime, + truncate, onReplyClick, } = props - const { text, content, userUsername, userName, userAvatarUrl, createdTime } = - comment + const { text, userUsername, userName, userAvatarUrl, createdTime } = comment let betOutcome: string | undefined, bought: string | undefined, money: string | undefined @@ -253,9 +276,11 @@ export function FeedComment(props: { elementId={comment.id} /> </div> - <div className="mt-2 text-[15px] text-gray-700"> - <Content content={content || text} /> - </div> + <TruncatedComment + comment={text} + moreHref={contractPath(contract)} + shouldTruncate={truncate} + /> <Row className="mt-2 items-center gap-6 text-xs text-gray-500"> <Tipper comment={comment} tips={tips ?? {}} /> {onReplyClick && ( @@ -320,7 +345,8 @@ export function CommentInput(props: { contract: Contract betsByCurrentUser: Bet[] commentsByCurrentUser: Comment[] - replyToUser?: { id: string; username: string } + replyToUsername?: string + setRef?: (ref: HTMLTextAreaElement) => void // Reply to a free response answer parentAnswerOutcome?: string // Reply to another comment @@ -333,18 +359,12 @@ export function CommentInput(props: { commentsByCurrentUser, parentAnswerOutcome, parentCommentId, - replyToUser, + replyToUsername, onSubmitComment, + setRef, } = props const user = useUser() - const { editor, upload } = useTextEditor({ - simple: true, - max: MAX_COMMENT_LENGTH, - placeholder: - !!parentCommentId || !!parentAnswerOutcome - ? 'Write a reply...' - : 'Write a comment...', - }) + const [comment, setComment] = useState('') const [isSubmitting, setIsSubmitting] = useState(false) const mostRecentCommentableBet = getMostRecentCommentableBet( @@ -360,17 +380,18 @@ export function CommentInput(props: { track('sign in to comment') return await firebaseLogin() } - if (!editor || editor.isEmpty || isSubmitting) return + if (!comment || isSubmitting) return setIsSubmitting(true) await createCommentOnContract( contract.id, - editor.getJSON(), + comment, user, betId, parentAnswerOutcome, parentCommentId ) onSubmitComment?.() + setComment('') setIsSubmitting(false) } @@ -425,12 +446,14 @@ export function CommentInput(props: { )} </div> <CommentInputTextArea - editor={editor} - upload={upload} - replyToUser={replyToUser} + commentText={comment} + setComment={setComment} + isReply={!!parentCommentId || !!parentAnswerOutcome} + replyToUsername={replyToUsername ?? ''} user={user} submitComment={submitComment} isSubmitting={isSubmitting} + setRef={setRef} presetId={id} /> </div> @@ -442,89 +465,94 @@ export function CommentInput(props: { export function CommentInputTextArea(props: { user: User | undefined | null - replyToUser?: { id: string; username: string } - editor: Editor | null - upload: Parameters<typeof TextEditor>[0]['upload'] + isReply: boolean + replyToUsername: string + commentText: string + setComment: (text: string) => void submitComment: (id?: string) => void isSubmitting: boolean - submitOnEnter?: boolean + setRef?: (ref: HTMLTextAreaElement) => void presetId?: string + enterToSubmitOnDesktop?: boolean }) { const { + isReply, + setRef, user, - editor, - upload, + commentText, + setComment, submitComment, presetId, isSubmitting, - submitOnEnter, - replyToUser, + replyToUsername, + enterToSubmitOnDesktop, } = props - const isMobile = (useWindowSize().width ?? 0) < 768 // TODO: base off input device (keybord vs touch) - + const { width } = useWindowSize() + const memoizedSetComment = useEvent(setComment) useEffect(() => { - editor?.setEditable(!isSubmitting) - }, [isSubmitting, editor]) - - const submit = () => { - submitComment(presetId) - editor?.commands?.clearContent() - } - - useEffect(() => { - if (!editor) { - return - } - // submit on Enter key - editor.setOptions({ - editorProps: { - handleKeyDown: (view, event) => { - if ( - submitOnEnter && - event.key === 'Enter' && - !event.shiftKey && - (!isMobile || event.ctrlKey || event.metaKey) && - // mention list is closed - !(view.state as any).mention$.active - ) { - submit() - event.preventDefault() - return true - } - return false - }, - }, - }) - // insert at mention - if (replyToUser) { - editor.commands.insertContentAt(0, { - type: 'mention', - attrs: { label: replyToUser.username, id: replyToUser.id }, - }) - editor.commands.focus() - } + if (!replyToUsername || !user || replyToUsername === user.username) return + const replacement = `@${replyToUsername} ` + memoizedSetComment(replacement + commentText.replace(replacement, '')) // eslint-disable-next-line react-hooks/exhaustive-deps - }, [editor]) - + }, [user, replyToUsername, memoizedSetComment]) return ( <> - <div> - <TextEditor editor={editor} upload={upload}> + <Row className="gap-1.5 text-gray-700"> + <Textarea + ref={setRef} + value={commentText} + onChange={(e) => setComment(e.target.value)} + className={clsx('textarea textarea-bordered w-full resize-none')} + // Make room for floating submit button. + style={{ paddingRight: 48 }} + placeholder={ + isReply + ? 'Write a reply... ' + : enterToSubmitOnDesktop + ? 'Send a message' + : 'Write a comment...' + } + autoFocus={false} + maxLength={MAX_COMMENT_LENGTH} + disabled={isSubmitting} + onKeyDown={(e) => { + if ( + (enterToSubmitOnDesktop && + e.key === 'Enter' && + !e.shiftKey && + width && + width > 768) || + (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) + ) { + e.preventDefault() + submitComment(presetId) + e.currentTarget.blur() + } + }} + /> + + <Col className={clsx('relative justify-end')}> {user && !isSubmitting && ( <button - className="btn btn-ghost btn-sm px-2 disabled:bg-inherit disabled:text-gray-300" - disabled={!editor || editor.isEmpty} - onClick={submit} + className={clsx( + 'btn btn-ghost btn-sm absolute right-2 bottom-2 flex-row pl-2 capitalize', + !commentText && 'pointer-events-none text-gray-500' + )} + onClick={() => { + submitComment(presetId) + }} > - <PaperAirplaneIcon className="m-0 h-[25px] min-w-[22px] rotate-90 p-0" /> + <PaperAirplaneIcon + className={'m-0 min-w-[22px] rotate-90 p-0 '} + height={25} + /> </button> )} - {isSubmitting && ( <LoadingIndicator spinnerClassName={'border-gray-500'} /> )} - </TextEditor> - </div> + </Col> + </Row> <Row> {!user && ( <button @@ -539,6 +567,38 @@ export function CommentInputTextArea(props: { ) } +export function TruncatedComment(props: { + comment: string + moreHref: string + shouldTruncate?: boolean +}) { + const { comment, moreHref, shouldTruncate } = props + let truncated = comment + + // Keep descriptions to at most 400 characters + const MAX_CHARS = 400 + if (shouldTruncate && truncated.length > MAX_CHARS) { + truncated = truncated.slice(0, MAX_CHARS) + // Make sure to end on a space + const i = truncated.lastIndexOf(' ') + truncated = truncated.slice(0, i) + } + + return ( + <div + className="mt-2 whitespace-pre-line break-words text-gray-700" + style={{ fontSize: 15 }} + > + <Linkify text={truncated} /> + {truncated != comment && ( + <SiteLink href={moreHref} className="text-indigo-700"> + ... (show more) + </SiteLink> + )} + </div> + ) +} + function getBettorsLargestPositionBeforeTime( contract: Contract, createdTime: number, diff --git a/web/components/groups/group-chat.tsx b/web/components/groups/group-chat.tsx index db7e558b..91de63c6 100644 --- a/web/components/groups/group-chat.tsx +++ b/web/components/groups/group-chat.tsx @@ -5,19 +5,24 @@ import React, { useEffect, memo, useState, useMemo } from 'react' import { Avatar } from 'web/components/avatar' import { Group } from 'common/group' import { Comment, createCommentOnGroup } from 'web/lib/firebase/comments' -import { CommentInputTextArea } from 'web/components/feed/feed-comments' +import { + CommentInputTextArea, + TruncatedComment, +} from 'web/components/feed/feed-comments' import { track } from 'web/lib/service/analytics' import { firebaseLogin } from 'web/lib/firebase/users' + import { useRouter } from 'next/router' import clsx from 'clsx' import { UserLink } from 'web/components/user-page' + +import { groupPath } from 'web/lib/firebase/groups' import { CopyLinkDateTimeComponent } from 'web/components/feed/copy-link-date-time' import { CommentTipMap, CommentTips } from 'web/hooks/use-tip-txns' import { Tipper } from 'web/components/tipper' import { sum } from 'lodash' import { formatMoney } from 'common/util/format' import { useWindowSize } from 'web/hooks/use-window-size' -import { Content, useTextEditor } from 'web/components/editor' import { useUnseenPreferredNotifications } from 'web/hooks/use-notifications' import { ChatIcon, ChevronDownIcon } from '@heroicons/react/outline' import { setNotificationsAsSeen } from 'web/pages/notifications' @@ -29,18 +34,16 @@ export function GroupChat(props: { tips: CommentTipMap }) { const { messages, user, group, tips } = props - const { editor, upload } = useTextEditor({ - simple: true, - placeholder: 'Send a message', - }) + const [messageText, setMessageText] = useState('') const [isSubmitting, setIsSubmitting] = useState(false) const [scrollToBottomRef, setScrollToBottomRef] = useState<HTMLDivElement | null>(null) const [scrollToMessageId, setScrollToMessageId] = useState('') const [scrollToMessageRef, setScrollToMessageRef] = useState<HTMLDivElement | null>(null) - const [replyToUser, setReplyToUser] = useState<any>() - + const [replyToUsername, setReplyToUsername] = useState('') + const [inputRef, setInputRef] = useState<HTMLTextAreaElement | null>(null) + const [groupedMessages, setGroupedMessages] = useState<Comment[]>([]) const router = useRouter() const isMember = user && group.memberIds.includes(user?.id) @@ -51,26 +54,25 @@ export function GroupChat(props: { const remainingHeight = (height ?? 0) - (containerRef?.offsetTop ?? 0) - bottomBarHeight - // array of groups, where each group is an array of messages that are displayed as one - const groupedMessages = useMemo(() => { + useMemo(() => { // Group messages with createdTime within 2 minutes of each other. - const tempGrouped: Comment[][] = [] + const tempMessages = [] for (let i = 0; i < messages.length; i++) { const message = messages[i] - if (i === 0) tempGrouped.push([message]) + if (i === 0) tempMessages.push({ ...message }) else { const prevMessage = messages[i - 1] const diff = message.createdTime - prevMessage.createdTime const creatorsMatch = message.userId === prevMessage.userId if (diff < 2 * 60 * 1000 && creatorsMatch) { - tempGrouped.at(-1)?.push(message) + tempMessages[tempMessages.length - 1].text += `\n${message.text}` } else { - tempGrouped.push([message]) + tempMessages.push({ ...message }) } } } - return tempGrouped + setGroupedMessages(tempMessages) }, [messages]) useEffect(() => { @@ -92,12 +94,11 @@ export function GroupChat(props: { useEffect(() => { // is mobile? - if (width && width > 720) focusInput() - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [width]) + if (inputRef && width && width > 720) inputRef.focus() + }, [inputRef, width]) function onReplyClick(comment: Comment) { - setReplyToUser({ id: comment.userId, username: comment.userUsername }) + setReplyToUsername(comment.userUsername) } async function submitMessage() { @@ -105,16 +106,13 @@ export function GroupChat(props: { track('sign in to comment') return await firebaseLogin() } - if (!editor || editor.isEmpty || isSubmitting) return + if (!messageText || isSubmitting) return setIsSubmitting(true) - await createCommentOnGroup(group.id, editor.getJSON(), user) - editor.commands.clearContent() + await createCommentOnGroup(group.id, messageText, user) + setMessageText('') setIsSubmitting(false) - setReplyToUser(undefined) - focusInput() - } - function focusInput() { - editor?.commands.focus() + setReplyToUsername('') + inputRef?.focus() } return ( @@ -125,20 +123,20 @@ export function GroupChat(props: { } ref={setScrollToBottomRef} > - {groupedMessages.map((messages) => ( + {groupedMessages.map((message) => ( <GroupMessage user={user} - key={`group ${messages[0].id}`} - comments={messages} + key={message.id} + comment={message} group={group} onReplyClick={onReplyClick} - highlight={messages[0].id === scrollToMessageId} + highlight={message.id === scrollToMessageId} setRef={ - scrollToMessageId === messages[0].id + scrollToMessageId === message.id ? setScrollToMessageRef : undefined } - tips={tips[messages[0].id] ?? {}} + tips={tips[message.id] ?? {}} /> ))} {messages.length === 0 && ( @@ -146,7 +144,7 @@ export function GroupChat(props: { No messages yet. Why not{isMember ? ` ` : ' join and '} <button className={'cursor-pointer font-bold text-gray-700'} - onClick={focusInput} + onClick={() => inputRef?.focus()} > add one? </button> @@ -164,13 +162,15 @@ export function GroupChat(props: { </div> <div className={'flex-1'}> <CommentInputTextArea - editor={editor} - upload={upload} + commentText={messageText} + setComment={setMessageText} + isReply={false} user={user} - replyToUser={replyToUser} + replyToUsername={replyToUsername} submitComment={submitMessage} isSubmitting={isSubmitting} - submitOnEnter + enterToSubmitOnDesktop={true} + setRef={setInputRef} /> </div> </div> @@ -292,18 +292,16 @@ function GroupChatNotificationsIcon(props: { const GroupMessage = memo(function GroupMessage_(props: { user: User | null | undefined - comments: Comment[] + comment: Comment group: Group onReplyClick?: (comment: Comment) => void setRef?: (ref: HTMLDivElement) => void highlight?: boolean tips: CommentTips }) { - const { comments, onReplyClick, group, setRef, highlight, user, tips } = props - const first = comments[0] - const { id, userUsername, userName, userAvatarUrl, createdTime } = first - - const isCreatorsComment = user && first.userId === user.id + const { comment, onReplyClick, group, setRef, highlight, user, tips } = props + const { text, userUsername, userName, userAvatarUrl, createdTime } = comment + const isCreatorsComment = user && comment.userId === user.id return ( <Col ref={setRef} @@ -333,21 +331,23 @@ const GroupMessage = memo(function GroupMessage_(props: { prefix={'group'} slug={group.slug} createdTime={createdTime} - elementId={id} + elementId={comment.id} + /> + </Row> + <Row className={'text-black'}> + <TruncatedComment + comment={text} + moreHref={groupPath(group.slug)} + shouldTruncate={false} /> </Row> - <div className="mt-2 text-black"> - {comments.map((comment) => ( - <Content content={comment.content || comment.text} /> - ))} - </div> <Row> {!isCreatorsComment && onReplyClick && ( <button className={ 'self-start py-1 text-xs font-bold text-gray-500 hover:underline' } - onClick={() => onReplyClick(first)} + onClick={() => onReplyClick(comment)} > Reply </button> @@ -357,7 +357,7 @@ const GroupMessage = memo(function GroupMessage_(props: { {formatMoney(sum(Object.values(tips)))} </span> )} - {!isCreatorsComment && <Tipper comment={first} tips={tips} />} + {!isCreatorsComment && <Tipper comment={comment} tips={tips} />} </Row> </Col> ) diff --git a/web/lib/firebase/comments.ts b/web/lib/firebase/comments.ts index e82c6d45..5775a2bb 100644 --- a/web/lib/firebase/comments.ts +++ b/web/lib/firebase/comments.ts @@ -14,7 +14,6 @@ import { User } from 'common/user' import { Comment } from 'common/comment' import { removeUndefinedProps } from 'common/util/object' import { track } from '@amplitude/analytics-browser' -import { JSONContent } from '@tiptap/react' export type { Comment } @@ -22,7 +21,7 @@ export const MAX_COMMENT_LENGTH = 10000 export async function createCommentOnContract( contractId: string, - content: JSONContent, + text: string, commenter: User, betId?: string, answerOutcome?: string, @@ -35,7 +34,7 @@ export async function createCommentOnContract( id: ref.id, contractId, userId: commenter.id, - content: content, + text: text.slice(0, MAX_COMMENT_LENGTH), createdTime: Date.now(), userName: commenter.name, userUsername: commenter.username, @@ -54,7 +53,7 @@ export async function createCommentOnContract( } export async function createCommentOnGroup( groupId: string, - content: JSONContent, + text: string, user: User, replyToCommentId?: string ) { @@ -63,7 +62,7 @@ export async function createCommentOnGroup( id: ref.id, groupId, userId: user.id, - content: content, + text: text.slice(0, MAX_COMMENT_LENGTH), createdTime: Date.now(), userName: user.name, userUsername: user.username, diff --git a/web/pages/[username]/[contractSlug].tsx b/web/pages/[username]/[contractSlug].tsx index 5866f899..0da6c994 100644 --- a/web/pages/[username]/[contractSlug].tsx +++ b/web/pages/[username]/[contractSlug].tsx @@ -354,6 +354,7 @@ function ContractTopTrades(props: { comment={commentsById[topCommentId]} tips={tips[topCommentId]} betsBySameUser={[betsById[topCommentId]]} + truncate={false} smallAvatar={false} /> </div> From 1e66f4d1402618b641d8fbefc3d01d9f16c0cee5 Mon Sep 17 00:00:00 2001 From: mantikoros <95266179+mantikoros@users.noreply.github.com> Date: Fri, 5 Aug 2022 00:22:45 -0500 Subject: [PATCH 46/83] Share row (#715) * Challenge bets * Store avatar url * Fix before and after probs * Check balance before creation * Calculate winning shares * pretty * Change winning value * Set shares to equal each other * Fix share challenge link * pretty * remove lib refs * Probability of bet is set to market * Remove peer pill * Cleanup * Button on contract page * don't show challenge if not binary or if resolved * challenge button (WIP) * fix accept challenge: don't change pool/probability * Opengraph preview [WIP] * elim lib * Edit og card props * Change challenge text * New card gen attempt * Get challenge on server * challenge button styling * Use env domain * Remove other window ref * Use challenge creator as avatar * Remove user name * Remove s from property, replace prob with outcome * challenge form * share text * Add in challenge parts to template and url * Challenge url params optional * Add challenge params to parse request * Parse please * Don't remove prob * Challenge card styling * Challenge card styling * Challenge card styling * Challenge card styling * Challenge card styling * Challenge card styling * Challenge card styling * Challenge card styling * Add to readme about how to dev og-image * Add emojis * button: gradient background, 2xl size * beautify accept bet screen * update question button * Add separate challenge template * Accepted challenge sharing card, fix accept bet call * accept challenge button * challenge winner page * create challenge screen * Your outcome/cost=> acceptorOutcome/cost * New create challenge panel * Fix main merge * Add challenge slug to bet and filter by it * Center title * Add helper text * Add FAQ section * Lint * Columnize the user areas in preview link too * Absolutely position * Spacing * Orientation * Restyle challenges list, cache contract name * Make copying easy on mobile * Link spacing * Fix spacing * qr codes! * put your challenges first * eslint * Changes to contract buttons and create challenge modal * Change titles around for current bet * Add back in contract title after winning * Cleanup * Add challenge enabled flag * Spacing of switch button * market share row * Add lite market endpoint * 500 mana email (#687) * Create 500-mana.html * Update 500-mana.html Fixed typos and links not working * Added "create a good market" guide added page creating-market.html For Stephen to set up condition (email 3 days after signing up) * Update 500-mana.html updated 500 Mana email (still need to make changes to create market guide) * email changes * sendOneWeekBonusEmail logic * add dayjs as dependency * don't use mailgun scheduling Co-authored-by: mantikoros <sgrugett@gmail.com> * Challenge Bets (#679) * Challenge bets * Store avatar url * Fix before and after probs * Check balance before creation * Calculate winning shares * pretty * Change winning value * Set shares to equal each other * Fix share challenge link * pretty * remove lib refs * Probability of bet is set to market * Remove peer pill * Cleanup * Button on contract page * don't show challenge if not binary or if resolved * challenge button (WIP) * fix accept challenge: don't change pool/probability * Opengraph preview [WIP] * elim lib * Edit og card props * Change challenge text * New card gen attempt * Get challenge on server * challenge button styling * Use env domain * Remove other window ref * Use challenge creator as avatar * Remove user name * Remove s from property, replace prob with outcome * challenge form * share text * Add in challenge parts to template and url * Challenge url params optional * Add challenge params to parse request * Parse please * Don't remove prob * Challenge card styling * Challenge card styling * Challenge card styling * Challenge card styling * Challenge card styling * Challenge card styling * Challenge card styling * Challenge card styling * Add to readme about how to dev og-image * Add emojis * button: gradient background, 2xl size * beautify accept bet screen * update question button * Add separate challenge template * Accepted challenge sharing card, fix accept bet call * accept challenge button * challenge winner page * create challenge screen * Your outcome/cost=> acceptorOutcome/cost * New create challenge panel * Fix main merge * Add challenge slug to bet and filter by it * Center title * Add helper text * Add FAQ section * Lint * Columnize the user areas in preview link too * Absolutely position * Spacing * Orientation * Restyle challenges list, cache contract name * Make copying easy on mobile * Link spacing * Fix spacing * qr codes! * put your challenges first * eslint * Changes to contract buttons and create challenge modal * Change titles around for current bet * Add back in contract title after winning * Cleanup * Add challenge enabled flag * Spacing of switch button * Put sharing qr code in modal Co-authored-by: mantikoros <sgrugett@gmail.com> * See challenges you've accepted too * Remove max height * Notify mentioned users on market publish (#683) * Add function to parse at mentions * Notify mentioned users on market create - refactor createNotification to accept list of recipients' ids * Switch comments/chat to rich text editor (#703) * Switch comments/chat to rich text editor * Remove TruncatedComment * Re-add submit on enter * Insert at mention on reply * Update editor style for send button * only submit on enter in chat * code review: refactor * use more specific type for upload * fix ESlint and errors from merge * fix trigger on every render eslint warning * Notify people mentioned in comment * fix type errors * Revert "Switch comments/chat to rich text editor (#703)" This reverts commit f52da72115bfacb0af5a4d54c137a936b33d9eee. * merge conflict * share modal * merge issue * eslint * bigger link icion Co-authored-by: Ian Philips <iansphilips@gmail.com> Co-authored-by: James Grugett <jahooma@gmail.com> Co-authored-by: SirSaltyy <104849031+SirSaltyy@users.noreply.github.com> Co-authored-by: Sinclair Chen <abc.sinclair@gmail.com> --- web/components/button.tsx | 2 +- .../challenges/create-challenge-modal.tsx | 248 ++++++++++++++++++ web/components/contract/contract-details.tsx | 12 +- .../contract/contract-info-dialog.tsx | 31 +-- web/components/contract/contract-overview.tsx | 52 +--- web/components/contract/share-modal.tsx | 77 ++++++ web/components/contract/share-row.tsx | 59 +++++ web/pages/challenges/index.tsx | 1 + 8 files changed, 394 insertions(+), 88 deletions(-) create mode 100644 web/components/challenges/create-challenge-modal.tsx create mode 100644 web/components/contract/share-modal.tsx create mode 100644 web/components/contract/share-row.tsx diff --git a/web/components/button.tsx b/web/components/button.tsx index 5c1e15f8..462670bd 100644 --- a/web/components/button.tsx +++ b/web/components/button.tsx @@ -52,7 +52,7 @@ export function Button(props: { color === 'gradient' && 'bg-gradient-to-r from-indigo-500 to-blue-500 text-white hover:from-indigo-700 hover:to-blue-700', color === 'gray-white' && - 'text-greyscale-6 hover:bg-greyscale-2 bg-white', + 'border-none bg-white text-gray-500 shadow-none hover:bg-gray-200', className )} disabled={disabled} diff --git a/web/components/challenges/create-challenge-modal.tsx b/web/components/challenges/create-challenge-modal.tsx new file mode 100644 index 00000000..3a0e857a --- /dev/null +++ b/web/components/challenges/create-challenge-modal.tsx @@ -0,0 +1,248 @@ +import clsx from 'clsx' +import dayjs from 'dayjs' +import React, { useEffect, useState } from 'react' +import { LinkIcon, SwitchVerticalIcon } from '@heroicons/react/outline' +import toast from 'react-hot-toast' + +import { Col } from '../layout/col' +import { Row } from '../layout/row' +import { Title } from '../title' +import { User } from 'common/user' +import { Modal } from 'web/components/layout/modal' +import { Button } from '../button' +import { createChallenge, getChallengeUrl } from 'web/lib/firebase/challenges' +import { BinaryContract } from 'common/contract' +import { SiteLink } from 'web/components/site-link' +import { formatMoney } from 'common/util/format' +import { NoLabel, YesLabel } from '../outcome-label' +import { QRCode } from '../qr-code' +import { copyToClipboard } from 'web/lib/util/copy' + +type challengeInfo = { + amount: number + expiresTime: number | null + message: string + outcome: 'YES' | 'NO' | number + acceptorAmount: number +} + +export function CreateChallengeModal(props: { + user: User | null | undefined + contract: BinaryContract + isOpen: boolean + setOpen: (open: boolean) => void +}) { + const { user, contract, isOpen, setOpen } = props + const [challengeSlug, setChallengeSlug] = useState('') + + return ( + <Modal open={isOpen} setOpen={setOpen} size={'sm'}> + <Col className="gap-4 rounded-md bg-white px-8 py-6"> + {/*// add a sign up to challenge button?*/} + {user && ( + <CreateChallengeForm + user={user} + contract={contract} + onCreate={async (newChallenge) => { + const challenge = await createChallenge({ + creator: user, + creatorAmount: newChallenge.amount, + expiresTime: newChallenge.expiresTime, + message: newChallenge.message, + acceptorAmount: newChallenge.acceptorAmount, + outcome: newChallenge.outcome, + contract: contract, + }) + challenge && setChallengeSlug(getChallengeUrl(challenge)) + }} + challengeSlug={challengeSlug} + /> + )} + </Col> + </Modal> + ) +} + +function CreateChallengeForm(props: { + user: User + contract: BinaryContract + onCreate: (m: challengeInfo) => Promise<void> + challengeSlug: string +}) { + const { user, onCreate, contract, challengeSlug } = props + const [isCreating, setIsCreating] = useState(false) + const [finishedCreating, setFinishedCreating] = useState(false) + const [error, setError] = useState<string>('') + const [editingAcceptorAmount, setEditingAcceptorAmount] = useState(false) + const defaultExpire = 'week' + + const defaultMessage = `${user.name} is challenging you to a bet! Do you think ${contract.question}` + + const [challengeInfo, setChallengeInfo] = useState<challengeInfo>({ + expiresTime: dayjs().add(2, defaultExpire).valueOf(), + outcome: 'YES', + amount: 100, + acceptorAmount: 100, + message: defaultMessage, + }) + useEffect(() => { + setError('') + }, [challengeInfo]) + + return ( + <> + {!finishedCreating && ( + <form + onSubmit={(e) => { + e.preventDefault() + if (user.balance < challengeInfo.amount) { + setError('You do not have enough mana to create this challenge') + return + } + setIsCreating(true) + onCreate(challengeInfo).finally(() => setIsCreating(false)) + setFinishedCreating(true) + }} + > + <Title className="!mt-2" text="Challenge a friend to bet " /> + <div className="mt-2 flex flex-col flex-wrap justify-center gap-x-5 gap-y-2"> + <div>You'll bet:</div> + <Row + className={ + 'form-control w-full max-w-xs items-center justify-between gap-4 pr-3' + } + > + <Col> + <div className="relative"> + <span className="absolute mx-3 mt-3.5 text-sm text-gray-400"> + M$ + </span> + <input + className="input input-bordered w-32 pl-10" + type="number" + min={1} + value={challengeInfo.amount} + onChange={(e) => + setChallengeInfo((m: challengeInfo) => { + return { + ...m, + amount: parseInt(e.target.value), + acceptorAmount: editingAcceptorAmount + ? m.acceptorAmount + : parseInt(e.target.value), + } + }) + } + /> + </div> + </Col> + <span className={''}>on</span> + {challengeInfo.outcome === 'YES' ? <YesLabel /> : <NoLabel />} + </Row> + <Row className={'mt-3 max-w-xs justify-end'}> + <Button + color={'gradient'} + className={'opacity-80'} + onClick={() => + setChallengeInfo((m: challengeInfo) => { + return { + ...m, + outcome: m.outcome === 'YES' ? 'NO' : 'YES', + } + }) + } + > + <SwitchVerticalIcon className={'h-4 w-4'} /> + </Button> + </Row> + <Row className={'items-center'}>If they bet:</Row> + <Row className={'max-w-xs items-center justify-between gap-4 pr-3'}> + <div className={'w-32 sm:mr-1'}> + {editingAcceptorAmount ? ( + <Col> + <div className="relative"> + <span className="absolute mx-3 mt-3.5 text-sm text-gray-400"> + M$ + </span> + <input + className="input input-bordered w-32 pl-10" + type="number" + min={1} + value={challengeInfo.acceptorAmount} + onChange={(e) => + setChallengeInfo((m: challengeInfo) => { + return { + ...m, + acceptorAmount: parseInt(e.target.value), + } + }) + } + /> + </div> + </Col> + ) : ( + <span className="ml-1 font-bold"> + {formatMoney(challengeInfo.acceptorAmount)} + </span> + )} + </div> + <span>on</span> + {challengeInfo.outcome === 'YES' ? <NoLabel /> : <YesLabel />} + </Row> + </div> + <Row + className={clsx( + 'mt-8', + !editingAcceptorAmount ? 'justify-between' : 'justify-end' + )} + > + {!editingAcceptorAmount && ( + <Button + color={'gray-white'} + onClick={() => setEditingAcceptorAmount(!editingAcceptorAmount)} + > + Edit + </Button> + )} + <Button + type="submit" + color={'indigo'} + className={clsx( + 'whitespace-nowrap drop-shadow-md', + isCreating ? 'disabled' : '' + )} + > + Continue + </Button> + </Row> + <Row className={'text-error'}>{error} </Row> + </form> + )} + {finishedCreating && ( + <> + <Title className="!my-0" text="Challenge Created!" /> + + <div>Share the challenge using the link.</div> + <button + onClick={() => { + copyToClipboard(challengeSlug) + toast('Link copied to clipboard!') + }} + className={'btn btn-outline mb-4 whitespace-nowrap normal-case'} + > + <LinkIcon className={'mr-2 h-5 w-5'} /> + Copy link + </button> + + <QRCode url={challengeSlug} className="self-center" /> + <Row className={'gap-1 text-gray-500'}> + See your other + <SiteLink className={'underline'} href={'/challenges'}> + challenges + </SiteLink> + </Row> + </> + )} + </> + ) +} diff --git a/web/components/contract/contract-details.tsx b/web/components/contract/contract-details.tsx index 7a7242a0..9d12496d 100644 --- a/web/components/contract/contract-details.tsx +++ b/web/components/contract/contract-details.tsx @@ -5,13 +5,13 @@ import { TrendingUpIcon, UserGroupIcon, } from '@heroicons/react/outline' + import { Row } from '../layout/row' import { formatMoney } from 'common/util/format' import { UserLink } from '../user-page' import { Contract, contractMetrics, - contractPath, updateContract, } from 'web/lib/firebase/contracts' import dayjs from 'dayjs' @@ -24,11 +24,9 @@ import { Bet } from 'common/bet' import NewContractBadge from '../new-contract-badge' import { UserFollowButton } from '../follow-button' import { DAY_MS } from 'common/util/time' -import { ShareIconButton } from 'web/components/share-icon-button' import { useUser } from 'web/hooks/use-user' import { Editor } from '@tiptap/react' import { exhibitExts } from 'common/util/parse' -import { ENV_CONFIG } from 'common/envs/constants' import { Button } from 'web/components/button' import { Modal } from 'web/components/layout/modal' import { Col } from 'web/components/layout/col' @@ -228,14 +226,6 @@ export function ContractDetails(props: { <div className="whitespace-nowrap">{volumeLabel}</div> </Row> - <ShareIconButton - copyPayload={`https://${ENV_CONFIG.domain}${contractPath(contract)}${ - user?.username && contract.creatorUsername !== user?.username - ? '?referrer=' + user?.username - : '' - }`} - toastClassName={'sm:-left-40 -left-24 min-w-[250%]'} - /> {!disabled && <ContractInfoDialog contract={contract} bets={bets} />} </Row> diff --git a/web/components/contract/contract-info-dialog.tsx b/web/components/contract/contract-info-dialog.tsx index a1f79479..168ada50 100644 --- a/web/components/contract/contract-info-dialog.tsx +++ b/web/components/contract/contract-info-dialog.tsx @@ -7,16 +7,12 @@ import { Bet } from 'common/bet' import { Contract } from 'common/contract' import { formatMoney } from 'common/util/format' -import { contractPath, contractPool } from 'web/lib/firebase/contracts' +import { contractPool } from 'web/lib/firebase/contracts' import { LiquidityPanel } from '../liquidity-panel' import { Col } from '../layout/col' import { Modal } from '../layout/modal' -import { Row } from '../layout/row' -import { ShareEmbedButton } from '../share-embed-button' import { Title } from '../title' -import { TweetButton } from '../tweet-button' import { InfoTooltip } from '../info-tooltip' -import { DuplicateContractButton } from '../copy-contract-button' 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' @@ -61,20 +57,6 @@ export function ContractInfoDialog(props: { contract: Contract; bets: Bet[] }) { <Col className="gap-4 rounded bg-white p-6"> <Title className="!mt-0 !mb-0" text="Market info" /> - <div>Share</div> - - <Row className="justify-start gap-4"> - <TweetButton - className="self-start" - tweetText={getTweetText(contract)} - /> - <ShareEmbedButton contract={contract} toastClassName={'-left-20'} /> - <DuplicateContractButton contract={contract} /> - </Row> - <div /> - - <div>Stats</div> - <table className="table-compact table-zebra table w-full text-gray-500"> <tbody> <tr> @@ -150,14 +132,3 @@ export function ContractInfoDialog(props: { contract: Contract; bets: Bet[] }) { </> ) } - -const getTweetText = (contract: Contract) => { - 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}` -} diff --git a/web/components/contract/contract-overview.tsx b/web/components/contract/contract-overview.tsx index 28eabb04..b95bb02b 100644 --- a/web/components/contract/contract-overview.tsx +++ b/web/components/contract/contract-overview.tsx @@ -1,12 +1,13 @@ -import { contractUrl, tradingAllowed } from 'web/lib/firebase/contracts' +import React from 'react' +import clsx from 'clsx' + +import { tradingAllowed } from 'web/lib/firebase/contracts' import { Col } from '../layout/col' import { Spacer } from '../layout/spacer' import { ContractProbGraph } from './contract-prob-graph' import { useUser } from 'web/hooks/use-user' import { Row } from '../layout/row' import { Linkify } from '../linkify' -import clsx from 'clsx' - import { BinaryResolutionOrChance, FreeResponseResolutionOrChance, @@ -20,12 +21,7 @@ import { Contract, CPMMBinaryContract } from 'common/contract' import { ContractDescription } from './contract-description' import { ContractDetails } from './contract-details' import { NumericGraph } from './numeric-graph' -import { CreateChallengeButton } from 'web/components/challenges/create-challenge-button' -import React from 'react' -import { copyToClipboard } from 'web/lib/util/copy' -import toast from 'react-hot-toast' -import { LinkIcon } from '@heroicons/react/outline' -import { CHALLENGES_ENABLED } from 'common/challenge' +import { ShareRow } from './share-row' export const ContractOverview = (props: { contract: Contract @@ -40,7 +36,6 @@ export const ContractOverview = (props: { const isBinary = outcomeType === 'BINARY' const isPseudoNumeric = outcomeType === 'PSEUDO_NUMERIC' - const showChallenge = user && isBinary && !resolution && CHALLENGES_ENABLED return ( <Col className={clsx('mb-6', className)}> @@ -123,47 +118,12 @@ export const ContractOverview = (props: { <AnswersGraph contract={contract} bets={bets} /> )} {outcomeType === 'NUMERIC' && <NumericGraph contract={contract} />} - {/* {(contract.description || isCreator) && <Spacer h={6} />} */} + <ShareRow user={user} contract={contract} /> <ContractDescription className="px-2" contract={contract} isCreator={isCreator} /> - {/*<Row className="mx-4 mt-4 hidden justify-around sm:block">*/} - {/* {showChallenge && (*/} - {/* <Col className="gap-3">*/} - {/* <div className="text-lg">⚔️ Challenge a friend ⚔️</div>*/} - {/* <CreateChallengeButton user={user} contract={contract} />*/} - {/* </Col>*/} - {/* )}*/} - {/* {isCreator && (*/} - {/* <Col className="gap-3">*/} - {/* <div className="text-lg">Share your market</div>*/} - {/* <ShareMarketButton contract={contract} />*/} - {/* </Col>*/} - {/* )}*/} - {/*</Row>*/} - <Row className="mx-4 mt-6 block justify-around"> - {showChallenge && ( - <Col className="gap-3"> - <CreateChallengeButton user={user} contract={contract} /> - </Col> - )} - {isCreator && ( - <Col className="gap-3"> - <button - onClick={() => { - copyToClipboard(contractUrl(contract)) - toast('Link copied to clipboard!') - }} - className={'btn btn-outline mb-4 whitespace-nowrap normal-case'} - > - <LinkIcon className={'mr-2 h-5 w-5'} /> - Share market - </button> - </Col> - )} - </Row> </Col> ) } diff --git a/web/components/contract/share-modal.tsx b/web/components/contract/share-modal.tsx new file mode 100644 index 00000000..017d3174 --- /dev/null +++ b/web/components/contract/share-modal.tsx @@ -0,0 +1,77 @@ +import { LinkIcon } from '@heroicons/react/outline' +import toast from 'react-hot-toast' + +import { Contract } from 'common/contract' +import { contractPath } from 'web/lib/firebase/contracts' +import { Col } from '../layout/col' +import { Modal } from '../layout/modal' +import { Row } from '../layout/row' +import { ShareEmbedButton } from '../share-embed-button' +import { Title } from '../title' +import { TweetButton } from '../tweet-button' +import { DuplicateContractButton } from '../copy-contract-button' +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' + +export function ShareModal(props: { + contract: Contract + user: User | undefined | null + isOpen: boolean + setOpen: (open: boolean) => void +}) { + const { contract, user, isOpen, setOpen } = props + + const linkIcon = <LinkIcon className="mr-2 h-6 w-6" aria-hidden="true" /> + + const copyPayload = `https://${ENV_CONFIG.domain}${contractPath(contract)}${ + user?.username && contract.creatorUsername !== user?.username + ? '?referrer=' + user?.username + : '' + }` + + return ( + <Modal open={isOpen} setOpen={setOpen}> + <Col className="gap-4 rounded bg-white p-4"> + <Title className="!mt-0 mb-2" text="Share this market" /> + + <Button + size="2xl" + color="gradient" + className={'mb-2 flex max-w-xs self-center'} + onClick={() => { + copyToClipboard(copyPayload) + track('copy share link') + toast.success('Link copied!', { + icon: linkIcon, + }) + }} + > + {linkIcon} Copy link + </Button> + + <Row className="justify-start gap-4 self-center"> + <TweetButton + className="self-start" + tweetText={getTweetText(contract)} + /> + <ShareEmbedButton contract={contract} toastClassName={'-left-20'} /> + <DuplicateContractButton contract={contract} /> + </Row> + </Col> + </Modal> + ) +} + +const getTweetText = (contract: Contract) => { + 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}` +} diff --git a/web/components/contract/share-row.tsx b/web/components/contract/share-row.tsx new file mode 100644 index 00000000..fd872c5a --- /dev/null +++ b/web/components/contract/share-row.tsx @@ -0,0 +1,59 @@ +import clsx from 'clsx' +import { ShareIcon } from '@heroicons/react/outline' + +import { Row } from '../layout/row' +import { Contract } from 'web/lib/firebase/contracts' +import { useState } from 'react' +import { Button } from 'web/components/button' +import { CreateChallengeModal } from '../challenges/create-challenge-modal' +import { User } from 'common/user' +import { CHALLENGES_ENABLED } from 'common/challenge' +import { ShareModal } from './share-modal' + +export function ShareRow(props: { + contract: Contract + user: User | undefined | null +}) { + const { user, contract } = props + const { outcomeType, resolution } = contract + + const showChallenge = + user && outcomeType === 'BINARY' && !resolution && CHALLENGES_ENABLED + + const [isOpen, setIsOpen] = useState(false) + const [isShareOpen, setShareOpen] = useState(false) + + return ( + <Row className="mt-2"> + <Button + size="lg" + color="gray-white" + className={'flex'} + onClick={() => { + setShareOpen(true) + }} + > + <ShareIcon className={clsx('mr-2 h-[24px] w-5')} aria-hidden="true" /> + Share + <ShareModal + isOpen={isShareOpen} + setOpen={setShareOpen} + contract={contract} + user={user} + /> + </Button> + + {showChallenge && ( + <Button size="lg" color="gray-white" onClick={() => setIsOpen(true)}> + ⚔️ Challenge + <CreateChallengeModal + isOpen={isOpen} + setOpen={setIsOpen} + user={user} + contract={contract} + /> + </Button> + )} + </Row> + ) +} diff --git a/web/pages/challenges/index.tsx b/web/pages/challenges/index.tsx index 7c68f0bd..e548e56f 100644 --- a/web/pages/challenges/index.tsx +++ b/web/pages/challenges/index.tsx @@ -113,6 +113,7 @@ function YourChallengesTable(props: { links: Challenge[] }) { function YourLinkSummaryRow(props: { challenge: Challenge }) { const { challenge } = props const { acceptances } = challenge + const [open, setOpen] = React.useState(false) const className = clsx( 'whitespace-nowrap text-sm hover:cursor-pointer text-gray-500 hover:bg-sky-50 bg-white' From 4d153755c1f2a53a15d4e9af1f0344be176985a6 Mon Sep 17 00:00:00 2001 From: mantikoros <sgrugett@gmail.com> Date: Thu, 4 Aug 2022 22:33:56 -0700 Subject: [PATCH 47/83] delete challenge button --- .../challenges/create-challenge-button.tsx | 255 ------------------ 1 file changed, 255 deletions(-) delete mode 100644 web/components/challenges/create-challenge-button.tsx diff --git a/web/components/challenges/create-challenge-button.tsx b/web/components/challenges/create-challenge-button.tsx deleted file mode 100644 index 6eab9bc5..00000000 --- a/web/components/challenges/create-challenge-button.tsx +++ /dev/null @@ -1,255 +0,0 @@ -import clsx from 'clsx' -import dayjs from 'dayjs' -import React, { useEffect, useState } from 'react' -import { LinkIcon, SwitchVerticalIcon } from '@heroicons/react/outline' - -import { Col } from '../layout/col' -import { Row } from '../layout/row' -import { Title } from '../title' -import { User } from 'common/user' -import { Modal } from 'web/components/layout/modal' -import { Button } from '../button' -import { createChallenge, getChallengeUrl } from 'web/lib/firebase/challenges' -import { BinaryContract } from 'common/contract' -import { SiteLink } from 'web/components/site-link' -import { formatMoney } from 'common/util/format' -import { NoLabel, YesLabel } from '../outcome-label' -import { QRCode } from '../qr-code' -import { copyToClipboard } from 'web/lib/util/copy' -import toast from 'react-hot-toast' - -type challengeInfo = { - amount: number - expiresTime: number | null - message: string - outcome: 'YES' | 'NO' | number - acceptorAmount: number -} -export function CreateChallengeButton(props: { - user: User | null | undefined - contract: BinaryContract -}) { - const { user, contract } = props - const [open, setOpen] = useState(false) - const [challengeSlug, setChallengeSlug] = useState('') - - return ( - <> - <Modal open={open} setOpen={(newOpen) => setOpen(newOpen)} size={'sm'}> - <Col className="gap-4 rounded-md bg-white px-8 py-6"> - {/*// add a sign up to challenge button?*/} - {user && ( - <CreateChallengeForm - user={user} - contract={contract} - onCreate={async (newChallenge) => { - const challenge = await createChallenge({ - creator: user, - creatorAmount: newChallenge.amount, - expiresTime: newChallenge.expiresTime, - message: newChallenge.message, - acceptorAmount: newChallenge.acceptorAmount, - outcome: newChallenge.outcome, - contract: contract, - }) - challenge && setChallengeSlug(getChallengeUrl(challenge)) - }} - challengeSlug={challengeSlug} - /> - )} - </Col> - </Modal> - - <button - onClick={() => setOpen(true)} - className="btn btn-outline mb-4 max-w-xs whitespace-nowrap normal-case" - > - Challenge a friend - </button> - </> - ) -} - -function CreateChallengeForm(props: { - user: User - contract: BinaryContract - onCreate: (m: challengeInfo) => Promise<void> - challengeSlug: string -}) { - const { user, onCreate, contract, challengeSlug } = props - const [isCreating, setIsCreating] = useState(false) - const [finishedCreating, setFinishedCreating] = useState(false) - const [error, setError] = useState<string>('') - const [editingAcceptorAmount, setEditingAcceptorAmount] = useState(false) - const defaultExpire = 'week' - - const defaultMessage = `${user.name} is challenging you to a bet! Do you think ${contract.question}` - - const [challengeInfo, setChallengeInfo] = useState<challengeInfo>({ - expiresTime: dayjs().add(2, defaultExpire).valueOf(), - outcome: 'YES', - amount: 100, - acceptorAmount: 100, - message: defaultMessage, - }) - useEffect(() => { - setError('') - }, [challengeInfo]) - - return ( - <> - {!finishedCreating && ( - <form - onSubmit={(e) => { - e.preventDefault() - if (user.balance < challengeInfo.amount) { - setError('You do not have enough mana to create this challenge') - return - } - setIsCreating(true) - onCreate(challengeInfo).finally(() => setIsCreating(false)) - setFinishedCreating(true) - }} - > - <Title className="!mt-2" text="Challenge a friend to bet " /> - <div className="mt-2 flex flex-col flex-wrap justify-center gap-x-5 gap-y-2"> - <div>You'll bet:</div> - <Row - className={ - 'form-control w-full max-w-xs items-center justify-between gap-4 pr-3' - } - > - <Col> - <div className="relative"> - <span className="absolute mx-3 mt-3.5 text-sm text-gray-400"> - M$ - </span> - <input - className="input input-bordered w-32 pl-10" - type="number" - min={1} - value={challengeInfo.amount} - onChange={(e) => - setChallengeInfo((m: challengeInfo) => { - return { - ...m, - amount: parseInt(e.target.value), - acceptorAmount: editingAcceptorAmount - ? m.acceptorAmount - : parseInt(e.target.value), - } - }) - } - /> - </div> - </Col> - <span className={''}>on</span> - {challengeInfo.outcome === 'YES' ? <YesLabel /> : <NoLabel />} - </Row> - <Row className={'mt-3 max-w-xs justify-end'}> - <Button - color={'gradient'} - className={'opacity-80'} - onClick={() => - setChallengeInfo((m: challengeInfo) => { - return { - ...m, - outcome: m.outcome === 'YES' ? 'NO' : 'YES', - } - }) - } - > - <SwitchVerticalIcon className={'h-4 w-4'} /> - </Button> - </Row> - <Row className={'items-center'}>If they bet:</Row> - <Row className={'max-w-xs items-center justify-between gap-4 pr-3'}> - <div className={'w-32 sm:mr-1'}> - {editingAcceptorAmount ? ( - <Col> - <div className="relative"> - <span className="absolute mx-3 mt-3.5 text-sm text-gray-400"> - M$ - </span> - <input - className="input input-bordered w-32 pl-10" - type="number" - min={1} - value={challengeInfo.acceptorAmount} - onChange={(e) => - setChallengeInfo((m: challengeInfo) => { - return { - ...m, - acceptorAmount: parseInt(e.target.value), - } - }) - } - /> - </div> - </Col> - ) : ( - <span className="ml-1 font-bold"> - {formatMoney(challengeInfo.acceptorAmount)} - </span> - )} - </div> - <span>on</span> - {challengeInfo.outcome === 'YES' ? <NoLabel /> : <YesLabel />} - </Row> - </div> - <Row - className={clsx( - 'mt-8', - !editingAcceptorAmount ? 'justify-between' : 'justify-end' - )} - > - {!editingAcceptorAmount && ( - <Button - color={'gray-white'} - onClick={() => setEditingAcceptorAmount(!editingAcceptorAmount)} - > - Edit - </Button> - )} - <Button - type="submit" - color={'indigo'} - className={clsx( - 'whitespace-nowrap drop-shadow-md', - isCreating ? 'disabled' : '' - )} - > - Continue - </Button> - </Row> - <Row className={'text-error'}>{error} </Row> - </form> - )} - {finishedCreating && ( - <> - <Title className="!my-0" text="Challenge Created!" /> - - <div>Share the challenge using the link.</div> - <button - onClick={() => { - copyToClipboard(challengeSlug) - toast('Link copied to clipboard!') - }} - className={'btn btn-outline mb-4 whitespace-nowrap normal-case'} - > - <LinkIcon className={'mr-2 h-5 w-5'} /> - Copy link - </button> - - <QRCode url={challengeSlug} className="self-center" /> - <Row className={'gap-1 text-gray-500'}> - See your other - <SiteLink className={'underline'} href={'/challenges'}> - challenges - </SiteLink> - </Row> - </> - )} - </> - ) -} From 16f4fb94900fc14cababb79a486d44790a85c1e4 Mon Sep 17 00:00:00 2001 From: mantikoros <sgrugett@gmail.com> Date: Thu, 4 Aug 2022 22:47:59 -0700 Subject: [PATCH 48/83] disable clicking on group in embed --- web/components/contract/contract-details.tsx | 34 ++++++++++++-------- 1 file changed, 21 insertions(+), 13 deletions(-) diff --git a/web/components/contract/contract-details.tsx b/web/components/contract/contract-details.tsx index 9d12496d..936f5e24 100644 --- a/web/components/contract/contract-details.tsx +++ b/web/components/contract/contract-details.tsx @@ -145,6 +145,15 @@ export function ContractDetails(props: { const user = useUser() const [open, setOpen] = useState(false) + const groupInfo = ( + <Row> + <UserGroupIcon className="mx-1 inline h-5 w-5 shrink-0" /> + <span className={'line-clamp-1'}> + {groupToDisplay ? groupToDisplay.name : 'No group'} + </span> + </Row> + ) + return ( <Row className="flex-1 flex-wrap items-center gap-x-4 gap-y-2 text-sm text-gray-500"> <Row className="items-center gap-2"> @@ -166,19 +175,18 @@ export function ContractDetails(props: { {!disabled && <UserFollowButton userId={creatorId} small />} </Row> <Row> - <Button - size={'xs'} - className={'max-w-[200px]'} - color={'gray-white'} - onClick={() => setOpen(!open)} - > - <Row> - <UserGroupIcon className="mx-1 inline h-5 w-5 shrink-0" /> - <span className={'line-clamp-1'}> - {groupToDisplay ? groupToDisplay.name : 'No group'} - </span> - </Row> - </Button> + {disabled ? ( + groupInfo + ) : ( + <Button + size={'xs'} + className={'max-w-[200px]'} + color={'gray-white'} + onClick={() => setOpen(!open)} + > + {groupInfo} + </Button> + )} </Row> <Modal open={open} setOpen={setOpen} size={'md'}> <Col From 5988dd1e484e9d390eb7de42de9192b6fcd6a8f5 Mon Sep 17 00:00:00 2001 From: mantikoros <sgrugett@gmail.com> Date: Thu, 4 Aug 2022 23:42:35 -0700 Subject: [PATCH 49/83] improved create challenge modal; 2xs button --- web/components/button.tsx | 3 +- .../challenges/create-challenge-modal.tsx | 143 +++++++++--------- 2 files changed, 75 insertions(+), 71 deletions(-) diff --git a/web/components/button.tsx b/web/components/button.tsx index 462670bd..57b2add9 100644 --- a/web/components/button.tsx +++ b/web/components/button.tsx @@ -5,7 +5,7 @@ export function Button(props: { className?: string onClick?: () => void children?: ReactNode - size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl' + size?: '2xs' | 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl' color?: | 'green' | 'red' @@ -29,6 +29,7 @@ export function Button(props: { } = props const sizeClasses = { + '2xs': 'px-2 py-1 text-xs', xs: 'px-2.5 py-1.5 text-sm', sm: 'px-3 py-2 text-sm', md: 'px-4 py-2 text-sm', diff --git a/web/components/challenges/create-challenge-modal.tsx b/web/components/challenges/create-challenge-modal.tsx index 3a0e857a..eca50f27 100644 --- a/web/components/challenges/create-challenge-modal.tsx +++ b/web/components/challenges/create-challenge-modal.tsx @@ -17,6 +17,8 @@ import { formatMoney } from 'common/util/format' import { NoLabel, YesLabel } from '../outcome-label' import { QRCode } from '../qr-code' import { copyToClipboard } from 'web/lib/util/copy' +import { AmountInput } from '../amount-input' +import { getProbability } from 'common/calculate' type challengeInfo = { amount: number @@ -36,7 +38,7 @@ export function CreateChallengeModal(props: { const [challengeSlug, setChallengeSlug] = useState('') return ( - <Modal open={isOpen} setOpen={setOpen} size={'sm'}> + <Modal open={isOpen} setOpen={setOpen}> <Col className="gap-4 rounded-md bg-white px-8 py-6"> {/*// add a sign up to challenge button?*/} {user && ( @@ -104,7 +106,13 @@ function CreateChallengeForm(props: { setFinishedCreating(true) }} > - <Title className="!mt-2" text="Challenge a friend to bet " /> + <Title className="!mt-2" text="Challenge bet " /> + + <div className="mb-8"> + Challenge a friend to bet on{' '} + <span className="underline">{contract.question}</span> + </div> + <div className="mt-2 flex flex-col flex-wrap justify-center gap-x-5 gap-y-2"> <div>You'll bet:</div> <Row @@ -112,37 +120,29 @@ function CreateChallengeForm(props: { 'form-control w-full max-w-xs items-center justify-between gap-4 pr-3' } > - <Col> - <div className="relative"> - <span className="absolute mx-3 mt-3.5 text-sm text-gray-400"> - M$ - </span> - <input - className="input input-bordered w-32 pl-10" - type="number" - min={1} - value={challengeInfo.amount} - onChange={(e) => - setChallengeInfo((m: challengeInfo) => { - return { - ...m, - amount: parseInt(e.target.value), - acceptorAmount: editingAcceptorAmount - ? m.acceptorAmount - : parseInt(e.target.value), - } - }) + <AmountInput + amount={challengeInfo.amount || undefined} + onChange={(newAmount) => + setChallengeInfo((m: challengeInfo) => { + return { + ...m, + amount: newAmount ?? 0, + acceptorAmount: editingAcceptorAmount + ? m.acceptorAmount + : newAmount ?? 0, } - /> - </div> - </Col> + }) + } + error={undefined} + label={'M$'} + inputClassName="w-24" + /> <span className={''}>on</span> {challengeInfo.outcome === 'YES' ? <YesLabel /> : <NoLabel />} </Row> <Row className={'mt-3 max-w-xs justify-end'}> <Button - color={'gradient'} - className={'opacity-80'} + color={'gray-white'} onClick={() => setChallengeInfo((m: challengeInfo) => { return { @@ -152,67 +152,70 @@ function CreateChallengeForm(props: { }) } > - <SwitchVerticalIcon className={'h-4 w-4'} /> + <SwitchVerticalIcon className={'h-6 w-6'} /> </Button> </Row> <Row className={'items-center'}>If they bet:</Row> <Row className={'max-w-xs items-center justify-between gap-4 pr-3'}> <div className={'w-32 sm:mr-1'}> - {editingAcceptorAmount ? ( - <Col> - <div className="relative"> - <span className="absolute mx-3 mt-3.5 text-sm text-gray-400"> - M$ - </span> - <input - className="input input-bordered w-32 pl-10" - type="number" - min={1} - value={challengeInfo.acceptorAmount} - onChange={(e) => - setChallengeInfo((m: challengeInfo) => { - return { - ...m, - acceptorAmount: parseInt(e.target.value), - } - }) - } - /> - </div> - </Col> - ) : ( - <span className="ml-1 font-bold"> - {formatMoney(challengeInfo.acceptorAmount)} - </span> - )} + <AmountInput + amount={challengeInfo.acceptorAmount || undefined} + onChange={(newAmount) => { + setEditingAcceptorAmount(true) + + setChallengeInfo((m: challengeInfo) => { + return { + ...m, + acceptorAmount: newAmount ?? 0, + } + }) + }} + error={undefined} + label={'M$'} + inputClassName="w-24" + /> </div> <span>on</span> {challengeInfo.outcome === 'YES' ? <NoLabel /> : <YesLabel />} </Row> </div> - <Row - className={clsx( - 'mt-8', - !editingAcceptorAmount ? 'justify-between' : 'justify-end' - )} + <Button + size="2xs" + color="gray" + onClick={() => { + setEditingAcceptorAmount(true) + + const p = getProbability(contract) + const prob = challengeInfo.outcome === 'YES' ? p : 1 - p + const { amount } = challengeInfo + const acceptorAmount = Math.round(amount / prob - amount) + setChallengeInfo({ ...challengeInfo, acceptorAmount }) + }} > - {!editingAcceptorAmount && ( - <Button - color={'gray-white'} - onClick={() => setEditingAcceptorAmount(!editingAcceptorAmount)} - > - Edit - </Button> - )} + Use market odds + </Button> + + <div className="mt-8"> + If the challenge is accepted, whoever is right will earn{' '} + <span className="font-semibold"> + {formatMoney( + challengeInfo.acceptorAmount + challengeInfo.amount || 0 + )} + </span>{' '} + in total. + </div> + + <Row className="mt-8 items-center"> <Button type="submit" - color={'indigo'} + color={'gradient'} + size="xl" className={clsx( 'whitespace-nowrap drop-shadow-md', isCreating ? 'disabled' : '' )} > - Continue + Create challenge bet </Button> </Row> <Row className={'text-error'}>{error} </Row> From f3704633ee16346ead7d903c7b85bde965a34031 Mon Sep 17 00:00:00 2001 From: mantikoros <sgrugett@gmail.com> Date: Fri, 5 Aug 2022 00:03:38 -0700 Subject: [PATCH 50/83] liquidity panel styling --- web/components/liquidity-panel.tsx | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/web/components/liquidity-panel.tsx b/web/components/liquidity-panel.tsx index 7ecadeb7..94cf63b5 100644 --- a/web/components/liquidity-panel.tsx +++ b/web/components/liquidity-panel.tsx @@ -11,7 +11,6 @@ import { useUserLiquidity } from 'web/hooks/use-liquidity' import { Tabs } from './layout/tabs' import { NoLabel, YesLabel } from './outcome-label' import { Col } from './layout/col' -import { InfoTooltip } from './info-tooltip' import { track } from 'web/lib/service/analytics' export function LiquidityPanel(props: { contract: CPMMContract }) { @@ -103,8 +102,7 @@ function AddLiquidityPanel(props: { contract: CPMMContract }) { return ( <> <div className="align-center mb-4 text-gray-500"> - Subsidize this market by adding M$ to the liquidity pool.{' '} - <InfoTooltip text="The greater the M$ subsidy, the greater the incentive for traders to participate, the more accurate the market will be." /> + Subsidize this market by adding M$ to the liquidity pool. </div> <Row> @@ -114,6 +112,7 @@ function AddLiquidityPanel(props: { contract: CPMMContract }) { label="M$" error={error} disabled={isLoading} + inputClassName="w-28" /> <button className={clsx('btn btn-primary ml-2', isLoading && 'btn-disabled')} From d90901b4e325a314d73a59c21f15caec2577de40 Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Fri, 5 Aug 2022 05:03:47 -0600 Subject: [PATCH 51/83] Check creator balance again upon acceptance --- functions/src/accept-challenge.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/functions/src/accept-challenge.ts b/functions/src/accept-challenge.ts index fa98c8c6..eae6ab55 100644 --- a/functions/src/accept-challenge.ts +++ b/functions/src/accept-challenge.ts @@ -47,7 +47,7 @@ export const acceptchallenge = newEndpoint({}, async (req, auth) => { const creatorDoc = firestore.doc(`users/${challenge.creatorId}`) const creatorSnap = await trans.get(creatorDoc) - if (!creatorSnap.exists) throw new APIError(400, 'User not found.') + if (!creatorSnap.exists) throw new APIError(400, 'Creator not found.') const creator = creatorSnap.data() as User const { @@ -61,6 +61,9 @@ export const acceptchallenge = newEndpoint({}, async (req, auth) => { if (user.balance < acceptorAmount) throw new APIError(400, 'Insufficient balance.') + if (creator.balance < creatorAmount) + throw new APIError(400, 'Creator has insufficient balance.') + const contract = anyContract as CPMMBinaryContract const shares = (1 / creatorOutcomeProb) * creatorAmount const createdTime = Date.now() From 97e3de4e0fc4d7d07b68af98204981d500d6b325 Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Fri, 5 Aug 2022 06:56:10 -0600 Subject: [PATCH 52/83] Show numeric values in card preview --- og-image/api/_lib/parser.ts | 2 ++ og-image/api/_lib/template.ts | 9 ++++++++- og-image/api/_lib/types.ts | 1 + web/components/SEO.tsx | 8 ++++++++ web/components/contract/contract-card-preview.tsx | 8 ++++++++ 5 files changed, 27 insertions(+), 1 deletion(-) diff --git a/og-image/api/_lib/parser.ts b/og-image/api/_lib/parser.ts index 1a0863bd..6d5c9b3d 100644 --- a/og-image/api/_lib/parser.ts +++ b/og-image/api/_lib/parser.ts @@ -16,6 +16,7 @@ export function parseRequest(req: IncomingMessage) { // Attributes for Manifold card: question, probability, + numericValue, metadata, creatorName, creatorUsername, @@ -71,6 +72,7 @@ export function parseRequest(req: IncomingMessage) { question: getString(question) || 'Will you create a prediction market on Manifold?', probability: getString(probability), + numericValue: getString(numericValue) || '', metadata: getString(metadata) || 'Jan 1  •  M$ 123 pool', creatorName: getString(creatorName) || 'Manifold Markets', creatorUsername: getString(creatorUsername) || 'ManifoldMarkets', diff --git a/og-image/api/_lib/template.ts b/og-image/api/_lib/template.ts index 1fe54554..e7b4fcaf 100644 --- a/og-image/api/_lib/template.ts +++ b/og-image/api/_lib/template.ts @@ -91,6 +91,7 @@ export function getHtml(parsedReq: ParsedRequest) { creatorName, creatorUsername, creatorAvatarUrl, + numericValue, } = parsedReq const MAX_QUESTION_CHARS = 100 const truncatedQuestion = @@ -147,8 +148,14 @@ export function getHtml(parsedReq: ParsedRequest) { <div class="text-indigo-700 text-6xl leading-tight"> ${truncatedQuestion} </div> - <div class="flex flex-col text-primary"> + <div class="flex flex-col text-primary text-center"> <div class="text-8xl">${probability}</div> + <span class='text-blue-400'> + <div class="text-8xl ">${ + numericValue !== '' && probability === '' ? numericValue : '' + }</div> + <div class="text-4xl">${numericValue !== '' ? 'expected' : ''}</div> + </span> <div class="text-4xl">${probability !== '' ? 'chance' : ''}</div> </div> </div> diff --git a/og-image/api/_lib/types.ts b/og-image/api/_lib/types.ts index 3ade016a..ef0a8135 100644 --- a/og-image/api/_lib/types.ts +++ b/og-image/api/_lib/types.ts @@ -14,6 +14,7 @@ export interface ParsedRequest { // Attributes for Manifold card: question: string probability: string + numericValue: string metadata: string creatorName: string creatorUsername: string diff --git a/web/components/SEO.tsx b/web/components/SEO.tsx index b1e0ca5f..08dee31e 100644 --- a/web/components/SEO.tsx +++ b/web/components/SEO.tsx @@ -9,6 +9,7 @@ export type OgCardProps = { creatorName: string creatorUsername: string creatorAvatarUrl?: string + numericValue?: string } function buildCardUrl(props: OgCardProps, challenge?: Challenge) { @@ -25,6 +26,12 @@ function buildCardUrl(props: OgCardProps, challenge?: Challenge) { props.probability === undefined ? '' : `&probability=${encodeURIComponent(props.probability ?? '')}` + + const numericValueParam = + props.numericValue === undefined + ? '' + : `&numericValue=${encodeURIComponent(props.numericValue ?? '')}` + const creatorAvatarUrlParam = props.creatorAvatarUrl === undefined ? '' @@ -41,6 +48,7 @@ function buildCardUrl(props: OgCardProps, challenge?: Challenge) { `https://manifold-og-image.vercel.app/m.png` + `?question=${encodeURIComponent(props.question)}` + probabilityParam + + numericValueParam + `&metadata=${encodeURIComponent(props.metadata)}` + `&creatorName=${encodeURIComponent(props.creatorName)}` + creatorAvatarUrlParam + diff --git a/web/components/contract/contract-card-preview.tsx b/web/components/contract/contract-card-preview.tsx index 06a7f7f6..354fe308 100644 --- a/web/components/contract/contract-card-preview.tsx +++ b/web/components/contract/contract-card-preview.tsx @@ -2,6 +2,8 @@ import { Contract } from 'common/contract' import { getBinaryProbPercent } from 'web/lib/firebase/contracts' import { richTextToString } from 'common/util/parse' import { contractTextDetails } from 'web/components/contract/contract-details' +import { getFormattedMappedValue } from 'common/pseudo-numeric' +import { getProbability } from 'common/calculate' export const getOpenGraphProps = (contract: Contract) => { const { @@ -16,6 +18,11 @@ export const getOpenGraphProps = (contract: Contract) => { const probPercent = outcomeType === 'BINARY' ? getBinaryProbPercent(contract) : undefined + const numericValue = + outcomeType === 'PSEUDO_NUMERIC' + ? getFormattedMappedValue(contract)(getProbability(contract)) + : undefined + const stringDesc = typeof desc === 'string' ? desc : richTextToString(desc) const description = resolution @@ -32,5 +39,6 @@ export const getOpenGraphProps = (contract: Contract) => { creatorUsername, creatorAvatarUrl, description, + numericValue, } } From 1c80bf1fafaba307f3845f4aedf72e1e5a18890d Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Fri, 5 Aug 2022 06:58:29 -0600 Subject: [PATCH 53/83] Chat icon => users icon --- web/components/groups/group-chat.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web/components/groups/group-chat.tsx b/web/components/groups/group-chat.tsx index 91de63c6..70605556 100644 --- a/web/components/groups/group-chat.tsx +++ b/web/components/groups/group-chat.tsx @@ -24,7 +24,7 @@ import { sum } from 'lodash' import { formatMoney } from 'common/util/format' import { useWindowSize } from 'web/hooks/use-window-size' import { useUnseenPreferredNotifications } from 'web/hooks/use-notifications' -import { ChatIcon, ChevronDownIcon } from '@heroicons/react/outline' +import { ChatIcon, ChevronDownIcon, UsersIcon } from '@heroicons/react/outline' import { setNotificationsAsSeen } from 'web/pages/notifications' export function GroupChat(props: { @@ -239,7 +239,7 @@ export function GroupChatInBubble(props: { }} > {!shouldShowChat ? ( - <ChatIcon className="h-10 w-10" aria-hidden="true" /> + <UsersIcon className="h-10 w-10" aria-hidden="true" /> ) : ( <ChevronDownIcon className={'h-10 w-10'} aria-hidden={'true'} /> )} From de6d5b388a00f8332dee15aa0fb6954f90ebc9a0 Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Fri, 5 Aug 2022 06:58:39 -0600 Subject: [PATCH 54/83] Lint --- web/components/groups/group-chat.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/components/groups/group-chat.tsx b/web/components/groups/group-chat.tsx index 70605556..47258b09 100644 --- a/web/components/groups/group-chat.tsx +++ b/web/components/groups/group-chat.tsx @@ -24,7 +24,7 @@ import { sum } from 'lodash' import { formatMoney } from 'common/util/format' import { useWindowSize } from 'web/hooks/use-window-size' import { useUnseenPreferredNotifications } from 'web/hooks/use-notifications' -import { ChatIcon, ChevronDownIcon, UsersIcon } from '@heroicons/react/outline' +import { ChevronDownIcon, UsersIcon } from '@heroicons/react/outline' import { setNotificationsAsSeen } from 'web/pages/notifications' export function GroupChat(props: { From f47b70dd3c4ba7462a444a3681bc2ab4cd717e1d Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Fri, 5 Aug 2022 07:08:41 -0600 Subject: [PATCH 55/83] Darken numeric preview text --- og-image/api/_lib/template.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/og-image/api/_lib/template.ts b/og-image/api/_lib/template.ts index e7b4fcaf..f59740c5 100644 --- a/og-image/api/_lib/template.ts +++ b/og-image/api/_lib/template.ts @@ -148,15 +148,15 @@ export function getHtml(parsedReq: ParsedRequest) { <div class="text-indigo-700 text-6xl leading-tight"> ${truncatedQuestion} </div> - <div class="flex flex-col text-primary text-center"> + <div class="flex flex-col text-primary"> <div class="text-8xl">${probability}</div> - <span class='text-blue-400'> + <div class="text-4xl">${probability !== '' ? 'chance' : ''}</div> + <span class='text-blue-500 text-center'> <div class="text-8xl ">${ numericValue !== '' && probability === '' ? numericValue : '' }</div> <div class="text-4xl">${numericValue !== '' ? 'expected' : ''}</div> </span> - <div class="text-4xl">${probability !== '' ? 'chance' : ''}</div> </div> </div> From 60ebadbbe53788641b9060b164f0d12f8390d56e Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Fri, 5 Aug 2022 09:58:02 -0600 Subject: [PATCH 56/83] Add not about donating winnings to charity --- .../challenges/[username]/[contractSlug]/[challengeSlug].tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/web/pages/challenges/[username]/[contractSlug]/[challengeSlug].tsx b/web/pages/challenges/[username]/[contractSlug]/[challengeSlug].tsx index 0df5b7d7..55e78616 100644 --- a/web/pages/challenges/[username]/[contractSlug]/[challengeSlug].tsx +++ b/web/pages/challenges/[username]/[contractSlug]/[challengeSlug].tsx @@ -92,6 +92,7 @@ export default function ChallengePage(props: { useSaveReferral(currentUser, { defaultReferrerUsername: challenge?.creatorUsername, + contractId: challenge?.contractId, }) if (!contract || !challenge) return <Custom404 /> @@ -171,7 +172,8 @@ function FAQ() { {toggleWhatIsMana && ( <Row className={'mx-4'}> Mana (M$) is the play-money used by our platform to keep track of your - bets. It's completely free for you and your friends to get started! + bets. It's completely free to get started, and you can donate your + winnings to charity! </Row> )} </Col> From ced404eb74993207ebcbef835f718db60c713c7c Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Fri, 5 Aug 2022 12:01:16 -0700 Subject: [PATCH 57/83] Local search filters on groups, exclude contractIds --- web/pages/contract-search-firestore.tsx | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/web/pages/contract-search-firestore.tsx b/web/pages/contract-search-firestore.tsx index ea42b38a..9039aa50 100644 --- a/web/pages/contract-search-firestore.tsx +++ b/web/pages/contract-search-firestore.tsx @@ -20,6 +20,8 @@ export default function ContractSearchFirestore(props: { additionalFilter?: { creatorId?: string tag?: string + excludeContractIds?: string[] + groupSlug?: string } }) { const contracts = useContracts() @@ -63,7 +65,7 @@ export default function ContractSearchFirestore(props: { } if (additionalFilter) { - const { creatorId, tag } = additionalFilter + const { creatorId, tag, groupSlug, excludeContractIds } = additionalFilter if (creatorId) { matches = matches.filter((c) => c.creatorId === creatorId) @@ -74,6 +76,14 @@ export default function ContractSearchFirestore(props: { c.lowercaseTags.includes(tag.toLowerCase()) ) } + + if (groupSlug) { + matches = matches.filter((c) => c.groupSlugs?.includes(groupSlug)) + } + + if (excludeContractIds) { + matches = matches.filter((c) => !excludeContractIds.includes(c.id)) + } } matches = matches.slice(0, MAX_CONTRACTS_RENDERED) From f11c9a334122993778ecc09e14c43705f95c5583 Mon Sep 17 00:00:00 2001 From: mantikoros <sgrugett@gmail.com> Date: Fri, 5 Aug 2022 13:38:12 -0700 Subject: [PATCH 58/83] bouncing challenge button (temporary gimmick) --- web/components/contract/share-row.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/web/components/contract/share-row.tsx b/web/components/contract/share-row.tsx index fd872c5a..fa86094f 100644 --- a/web/components/contract/share-row.tsx +++ b/web/components/contract/share-row.tsx @@ -44,7 +44,12 @@ export function ShareRow(props: { </Button> {showChallenge && ( - <Button size="lg" color="gray-white" onClick={() => setIsOpen(true)}> + <Button + size="lg" + color="gray-white" + onClick={() => setIsOpen(true)} + className="animate-bounce" + > ⚔️ Challenge <CreateChallengeModal isOpen={isOpen} From 5e896285934c3e49724cf07b69e8f5242e59805a Mon Sep 17 00:00:00 2001 From: mantikoros <sgrugett@gmail.com> Date: Fri, 5 Aug 2022 13:42:02 -0700 Subject: [PATCH 59/83] challenge bet tracking --- web/components/challenges/create-challenge-modal.tsx | 10 +++++++++- web/components/contract/share-row.tsx | 6 +++++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/web/components/challenges/create-challenge-modal.tsx b/web/components/challenges/create-challenge-modal.tsx index eca50f27..e93ec314 100644 --- a/web/components/challenges/create-challenge-modal.tsx +++ b/web/components/challenges/create-challenge-modal.tsx @@ -19,6 +19,7 @@ import { QRCode } from '../qr-code' import { copyToClipboard } from 'web/lib/util/copy' import { AmountInput } from '../amount-input' import { getProbability } from 'common/calculate' +import { track } from 'web/lib/service/analytics' type challengeInfo = { amount: number @@ -55,7 +56,14 @@ export function CreateChallengeModal(props: { outcome: newChallenge.outcome, contract: contract, }) - challenge && setChallengeSlug(getChallengeUrl(challenge)) + if (challenge) { + setChallengeSlug(getChallengeUrl(challenge)) + track('challenge created', { + creator: user.username, + amount: newChallenge.amount, + contractId: contract.id, + }) + } }} challengeSlug={challengeSlug} /> diff --git a/web/components/contract/share-row.tsx b/web/components/contract/share-row.tsx index fa86094f..9c8c1573 100644 --- a/web/components/contract/share-row.tsx +++ b/web/components/contract/share-row.tsx @@ -9,6 +9,7 @@ import { CreateChallengeModal } from '../challenges/create-challenge-modal' import { User } from 'common/user' import { CHALLENGES_ENABLED } from 'common/challenge' import { ShareModal } from './share-modal' +import { withTracking } from 'web/lib/service/analytics' export function ShareRow(props: { contract: Contract @@ -47,7 +48,10 @@ export function ShareRow(props: { <Button size="lg" color="gray-white" - onClick={() => setIsOpen(true)} + onClick={withTracking( + () => setIsOpen(true), + 'click challenge button' + )} className="animate-bounce" > ⚔️ Challenge From 67139b99f904a91f7741511754b22c86882efe02 Mon Sep 17 00:00:00 2001 From: Marshall Polaris <marshall@pol.rs> Date: Fri, 5 Aug 2022 15:33:34 -0700 Subject: [PATCH 60/83] Add workflow to automate prettier running on main (#720) --- .github/workflows/format.yml | 38 ++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 .github/workflows/format.yml diff --git a/.github/workflows/format.yml b/.github/workflows/format.yml new file mode 100644 index 00000000..feee8758 --- /dev/null +++ b/.github/workflows/format.yml @@ -0,0 +1,38 @@ +name: Reformat main + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +on: + push: + branches: [main] + +env: + FORCE_COLOR: 3 + NEXT_TELEMETRY_DISABLED: 1 + +jobs: + prettify: + name: Auto-prettify + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout code + uses: actions/checkout@v3 + - name: Restore cached node_modules + uses: actions/cache@v2 + with: + path: '**/node_modules' + key: ${{ runner.os }}-${{ matrix.node-version }}-nodemodules-${{ hashFiles('**/yarn.lock') }} + - name: Install missing dependencies + run: yarn install --prefer-offline --frozen-lockfile + - name: Run Prettier on web client + working-directory: web + run: yarn format + - name: Commit any Prettier changes + uses: stefanzweifel/git-auto-commit-action@v4 + with: + commit_message: Auto-prettification + branch: ${{ github.head_ref }} From d9ddabcfd4b8ce0619ec00d1af1f5da2807f40e6 Mon Sep 17 00:00:00 2001 From: Marshall Polaris <marshall@pol.rs> Date: Fri, 5 Aug 2022 15:35:57 -0700 Subject: [PATCH 61/83] Commit some un-pretty code --- web/test.js | 1 + 1 file changed, 1 insertion(+) create mode 100644 web/test.js diff --git a/web/test.js b/web/test.js new file mode 100644 index 00000000..ef4a8a0f --- /dev/null +++ b/web/test.js @@ -0,0 +1 @@ + // this comment isn't very pretty From db3b0c2cf5b5691598df2ba2436f986288286ce4 Mon Sep 17 00:00:00 2001 From: Marshall Polaris <marshall@pol.rs> Date: Fri, 5 Aug 2022 15:38:22 -0700 Subject: [PATCH 62/83] Give repo write permission to formatting workflow --- .github/workflows/format.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/format.yml b/.github/workflows/format.yml index feee8758..8be333a3 100644 --- a/.github/workflows/format.yml +++ b/.github/workflows/format.yml @@ -12,6 +12,9 @@ env: FORCE_COLOR: 3 NEXT_TELEMETRY_DISABLED: 1 +permissions: + contents: write + jobs: prettify: name: Auto-prettify From f05db0ef0f3c03a43ccd7ec32001a88e7b92093e Mon Sep 17 00:00:00 2001 From: Marshall Polaris <marshall@pol.rs> Date: Fri, 5 Aug 2022 15:56:10 -0700 Subject: [PATCH 63/83] Give formatting workflow even more permissions... --- .github/workflows/format.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/format.yml b/.github/workflows/format.yml index 8be333a3..815c7732 100644 --- a/.github/workflows/format.yml +++ b/.github/workflows/format.yml @@ -12,8 +12,7 @@ env: FORCE_COLOR: 3 NEXT_TELEMETRY_DISABLED: 1 -permissions: - contents: write +permissions: write-all jobs: prettify: From 7e0634aee07cdc2b49ddf04ec3b9c75f9c9b5bfb Mon Sep 17 00:00:00 2001 From: Marshall Polaris <marshall@pol.rs> Date: Fri, 5 Aug 2022 16:02:46 -0700 Subject: [PATCH 64/83] Try using a personal access token for formatter job --- .github/workflows/format.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/format.yml b/.github/workflows/format.yml index 815c7732..fb850606 100644 --- a/.github/workflows/format.yml +++ b/.github/workflows/format.yml @@ -12,8 +12,6 @@ env: FORCE_COLOR: 3 NEXT_TELEMETRY_DISABLED: 1 -permissions: write-all - jobs: prettify: name: Auto-prettify @@ -23,6 +21,8 @@ jobs: steps: - name: Checkout code uses: actions/checkout@v3 + with: + token: ${{ secrets.FORMATTER_ACCESS_TOKEN }} - name: Restore cached node_modules uses: actions/cache@v2 with: From bba9f9a5551c4a0e001fe9954fd213c3a0c220ec Mon Sep 17 00:00:00 2001 From: mqp <mqp@users.noreply.github.com> Date: Fri, 5 Aug 2022 23:03:25 +0000 Subject: [PATCH 65/83] Auto-prettification --- web/test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/test.js b/web/test.js index ef4a8a0f..219046be 100644 --- a/web/test.js +++ b/web/test.js @@ -1 +1 @@ - // this comment isn't very pretty +// this comment isn't very pretty From bf3ba8ac3f27a6ed40837e0318399437ed678579 Mon Sep 17 00:00:00 2001 From: Marshall Polaris <marshall@pol.rs> Date: Fri, 5 Aug 2022 16:07:02 -0700 Subject: [PATCH 66/83] Remove test file --- web/test.js | 1 - 1 file changed, 1 deletion(-) delete mode 100644 web/test.js diff --git a/web/test.js b/web/test.js deleted file mode 100644 index 219046be..00000000 --- a/web/test.js +++ /dev/null @@ -1 +0,0 @@ -// this comment isn't very pretty From 48ac21ffad65882f8abe8e537cb5d98fa16ec509 Mon Sep 17 00:00:00 2001 From: Marshall Polaris <marshall@pol.rs> Date: Fri, 5 Aug 2022 16:08:30 -0700 Subject: [PATCH 67/83] Add comment explaining fishy token --- .github/workflows/format.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/format.yml b/.github/workflows/format.yml index fb850606..2aa95e44 100644 --- a/.github/workflows/format.yml +++ b/.github/workflows/format.yml @@ -12,6 +12,9 @@ env: FORCE_COLOR: 3 NEXT_TELEMETRY_DISABLED: 1 +# mqp - i generated a personal token to use for these writes -- it's unclear +# why, but the default token didn't work, even when i gave it max permissions + jobs: prettify: name: Auto-prettify From b3b06896bec8e3f324516dcf18c23b124aa78e96 Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Fri, 5 Aug 2022 17:44:55 -0700 Subject: [PATCH 68/83] Add loading indicator when algolia search is initially loading --- web/components/contract-search.tsx | 2 +- web/components/contract/contracts-list.tsx | 7 ++++++- web/pages/contract-search-firestore.tsx | 17 ++++++----------- 3 files changed, 13 insertions(+), 13 deletions(-) diff --git a/web/components/contract-search.tsx b/web/components/contract-search.tsx index c1e63175..607a4668 100644 --- a/web/components/contract-search.tsx +++ b/web/components/contract-search.tsx @@ -343,7 +343,7 @@ export function ContractSearch(props: { <>You're not following anyone, nor in any of your own groups yet.</> ) : ( <ContractsGrid - contracts={contracts} + contracts={hitsByPage[0] === undefined ? undefined : contracts} loadMore={loadMore} hasMore={true} showTime={showTime} diff --git a/web/components/contract/contracts-list.tsx b/web/components/contract/contracts-list.tsx index c733bd76..31a564d3 100644 --- a/web/components/contract/contracts-list.tsx +++ b/web/components/contract/contracts-list.tsx @@ -8,6 +8,7 @@ import { ContractSearch } from '../contract-search' import { useIsVisible } from 'web/hooks/use-is-visible' import { useEffect, useState } from 'react' import clsx from 'clsx' +import { LoadingIndicator } from '../loading-indicator' export type ContractHighlightOptions = { contractIds?: string[] @@ -15,7 +16,7 @@ export type ContractHighlightOptions = { } export function ContractsGrid(props: { - contracts: Contract[] + contracts: Contract[] | undefined loadMore: () => void hasMore: boolean showTime?: ShowTime @@ -49,6 +50,10 @@ export function ContractsGrid(props: { } }, [isBottomVisible, hasMore, loadMore]) + if (contracts === undefined) { + return <LoadingIndicator /> + } + if (contracts.length === 0) { return ( <p className="mx-2 text-gray-500"> diff --git a/web/pages/contract-search-firestore.tsx b/web/pages/contract-search-firestore.tsx index 9039aa50..7bb42a05 100644 --- a/web/pages/contract-search-firestore.tsx +++ b/web/pages/contract-search-firestore.tsx @@ -3,7 +3,6 @@ import { searchInAny } from 'common/util/parse' import { sortBy } from 'lodash' import { useState } from 'react' import { ContractsGrid } from 'web/components/contract/contracts-list' -import { LoadingIndicator } from 'web/components/loading-indicator' import { useContracts } from 'web/hooks/use-contracts' import { Sort, @@ -118,16 +117,12 @@ export default function ContractSearchFirestore(props: { <option value="close-date">Closing soon</option> </select> </div> - {contracts === undefined ? ( - <LoadingIndicator /> - ) : ( - <ContractsGrid - contracts={matches} - loadMore={() => {}} - hasMore={false} - showTime={showTime} - /> - )} + <ContractsGrid + contracts={matches} + loadMore={() => {}} + hasMore={false} + showTime={showTime} + /> </div> ) } From e0196f7107435978ff2a37341f725242bf6ffb91 Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Fri, 5 Aug 2022 17:46:32 -0700 Subject: [PATCH 69/83] Rename file contracts-list to contracts-group --- web/components/contract-search.tsx | 2 +- .../contract/{contracts-list.tsx => contracts-grid.tsx} | 0 web/components/landing-page-panel.tsx | 2 +- web/components/user-page.tsx | 2 +- web/pages/contract-search-firestore.tsx | 2 +- 5 files changed, 4 insertions(+), 4 deletions(-) rename web/components/contract/{contracts-list.tsx => contracts-grid.tsx} (100%) diff --git a/web/components/contract-search.tsx b/web/components/contract-search.tsx index 607a4668..265b25c6 100644 --- a/web/components/contract-search.tsx +++ b/web/components/contract-search.tsx @@ -6,7 +6,7 @@ import { Sort, useQueryAndSortParams } from '../hooks/use-sort-and-query-params' import { ContractHighlightOptions, ContractsGrid, -} from './contract/contracts-list' +} from './contract/contracts-grid' import { Row } from './layout/row' import { useEffect, useMemo, useState } from 'react' import { Spacer } from './layout/spacer' diff --git a/web/components/contract/contracts-list.tsx b/web/components/contract/contracts-grid.tsx similarity index 100% rename from web/components/contract/contracts-list.tsx rename to web/components/contract/contracts-grid.tsx diff --git a/web/components/landing-page-panel.tsx b/web/components/landing-page-panel.tsx index bcfdaf1e..4b436442 100644 --- a/web/components/landing-page-panel.tsx +++ b/web/components/landing-page-panel.tsx @@ -4,7 +4,7 @@ import { Contract } from 'common/contract' import { Spacer } from './layout/spacer' import { firebaseLogin } from 'web/lib/firebase/users' -import { ContractsGrid } from './contract/contracts-list' +import { ContractsGrid } from './contract/contracts-grid' import { Col } from './layout/col' import { Row } from './layout/row' import { withTracking } from 'web/lib/service/analytics' diff --git a/web/components/user-page.tsx b/web/components/user-page.tsx index d628e92d..fb349149 100644 --- a/web/components/user-page.tsx +++ b/web/components/user-page.tsx @@ -12,7 +12,7 @@ import { unfollow, User, } from 'web/lib/firebase/users' -import { CreatorContractsList } from './contract/contracts-list' +import { CreatorContractsList } from './contract/contracts-grid' import { SEO } from './SEO' import { Page } from './page' import { SiteLink } from './site-link' diff --git a/web/pages/contract-search-firestore.tsx b/web/pages/contract-search-firestore.tsx index 7bb42a05..9a09b101 100644 --- a/web/pages/contract-search-firestore.tsx +++ b/web/pages/contract-search-firestore.tsx @@ -2,7 +2,7 @@ import { Answer } from 'common/answer' import { searchInAny } from 'common/util/parse' import { sortBy } from 'lodash' import { useState } from 'react' -import { ContractsGrid } from 'web/components/contract/contracts-list' +import { ContractsGrid } from 'web/components/contract/contracts-grid' import { useContracts } from 'web/hooks/use-contracts' import { Sort, From d43b9e1836be524a956bf5dc19486f75d34be855 Mon Sep 17 00:00:00 2001 From: Marshall Polaris <marshall@pol.rs> Date: Fri, 5 Aug 2022 20:49:29 -0700 Subject: [PATCH 70/83] Vercel auth phase 2 (#714) * Add cloud function to get custom token from API auth * Use custom token to authenticate Firebase SDK on server * Make sure getcustomtoken cloud function is fast * Make server auth code maximally robust * Refactor cookie code, make set and delete more flexible --- functions/src/api.ts | 24 +++-- functions/src/get-custom-token.ts | 33 +++++++ functions/src/index.ts | 3 + functions/src/serve.ts | 2 + web/components/auth-context.tsx | 9 +- web/lib/firebase/auth.ts | 98 +++++++++++-------- web/lib/firebase/server-auth.ts | 153 ++++++++++++++++++++++++------ web/pages/notifications.tsx | 13 +-- 8 files changed, 245 insertions(+), 90 deletions(-) create mode 100644 functions/src/get-custom-token.ts diff --git a/functions/src/api.ts b/functions/src/api.ts index fdda0ad5..e9a488c2 100644 --- a/functions/src/api.ts +++ b/functions/src/api.ts @@ -78,6 +78,19 @@ export const lookupUser = async (creds: Credentials): Promise<AuthedUser> => { } } +export const writeResponseError = (e: unknown, res: Response) => { + if (e instanceof APIError) { + const output: { [k: string]: unknown } = { message: e.message } + if (e.details != null) { + output.details = e.details + } + res.status(e.code).json(output) + } else { + error(e) + res.status(500).json({ message: 'An unknown error occurred.' }) + } +} + export const zTimestamp = () => { return z.preprocess((arg) => { return typeof arg == 'number' ? new Date(arg) : undefined @@ -131,16 +144,7 @@ export const newEndpoint = (endpointOpts: EndpointOptions, fn: Handler) => { const authedUser = await lookupUser(await parseCredentials(req)) res.status(200).json(await fn(req, authedUser)) } catch (e) { - if (e instanceof APIError) { - const output: { [k: string]: unknown } = { message: e.message } - if (e.details != null) { - output.details = e.details - } - res.status(e.code).json(output) - } else { - error(e) - res.status(500).json({ message: 'An unknown error occurred.' }) - } + writeResponseError(e, res) } }, } as EndpointDefinition diff --git a/functions/src/get-custom-token.ts b/functions/src/get-custom-token.ts new file mode 100644 index 00000000..4aaaac11 --- /dev/null +++ b/functions/src/get-custom-token.ts @@ -0,0 +1,33 @@ +import * as admin from 'firebase-admin' +import { + APIError, + EndpointDefinition, + lookupUser, + parseCredentials, + writeResponseError, +} from './api' + +const opts = { + method: 'GET', + minInstances: 1, + concurrency: 100, + memory: '2GiB', + cpu: 1, +} as const + +export const getcustomtoken: EndpointDefinition = { + opts, + handler: async (req, res) => { + try { + const credentials = await parseCredentials(req) + if (credentials.kind != 'jwt') { + throw new APIError(403, 'API keys cannot mint custom tokens.') + } + const user = await lookupUser(credentials) + const token = await admin.auth().createCustomToken(user.uid) + res.status(200).json({ token: token }) + } catch (e) { + writeResponseError(e, res) + } + }, +} diff --git a/functions/src/index.ts b/functions/src/index.ts index 125cdea4..07b37648 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -65,6 +65,7 @@ import { unsubscribe } from './unsubscribe' import { stripewebhook, createcheckoutsession } from './stripe' import { getcurrentuser } from './get-current-user' import { acceptchallenge } from './accept-challenge' +import { getcustomtoken } from './get-custom-token' const toCloudFunction = ({ opts, handler }: EndpointDefinition) => { return onRequest(opts, handler as any) @@ -89,6 +90,7 @@ const stripeWebhookFunction = toCloudFunction(stripewebhook) const createCheckoutSessionFunction = toCloudFunction(createcheckoutsession) const getCurrentUserFunction = toCloudFunction(getcurrentuser) const acceptChallenge = toCloudFunction(acceptchallenge) +const getCustomTokenFunction = toCloudFunction(getcustomtoken) export { healthFunction as health, @@ -111,4 +113,5 @@ export { createCheckoutSessionFunction as createcheckoutsession, getCurrentUserFunction as getcurrentuser, acceptChallenge as acceptchallenge, + getCustomTokenFunction as getcustomtoken, } diff --git a/functions/src/serve.ts b/functions/src/serve.ts index 0064b69f..bf96db20 100644 --- a/functions/src/serve.ts +++ b/functions/src/serve.ts @@ -26,6 +26,7 @@ import { resolvemarket } from './resolve-market' import { unsubscribe } from './unsubscribe' import { stripewebhook, createcheckoutsession } from './stripe' import { getcurrentuser } from './get-current-user' +import { getcustomtoken } from './get-custom-token' type Middleware = (req: Request, res: Response, next: NextFunction) => void const app = express() @@ -64,6 +65,7 @@ addJsonEndpointRoute('/resolvemarket', resolvemarket) addJsonEndpointRoute('/unsubscribe', unsubscribe) addJsonEndpointRoute('/createcheckoutsession', createcheckoutsession) addJsonEndpointRoute('/getcurrentuser', getcurrentuser) +addEndpointRoute('/getcustomtoken', getcustomtoken) addEndpointRoute('/stripewebhook', stripewebhook, express.raw()) app.listen(PORT) diff --git a/web/components/auth-context.tsx b/web/components/auth-context.tsx index 653368b6..24adde25 100644 --- a/web/components/auth-context.tsx +++ b/web/components/auth-context.tsx @@ -7,7 +7,7 @@ import { getUser, setCachedReferralInfoForUser, } from 'web/lib/firebase/users' -import { deleteAuthCookies, setAuthCookies } from 'web/lib/firebase/auth' +import { deleteTokenCookies, setTokenCookies } from 'web/lib/firebase/auth' import { createUser } from 'web/lib/firebase/api' import { randomString } from 'common/util/random' import { identifyUser, setUserProperty } from 'web/lib/service/analytics' @@ -41,7 +41,10 @@ export function AuthProvider({ children }: any) { useEffect(() => { return onIdTokenChanged(auth, async (fbUser) => { if (fbUser) { - setAuthCookies(await fbUser.getIdToken(), fbUser.refreshToken) + setTokenCookies({ + id: await fbUser.getIdToken(), + refresh: fbUser.refreshToken, + }) let user = await getUser(fbUser.uid) if (!user) { const deviceToken = ensureDeviceToken() @@ -54,7 +57,7 @@ export function AuthProvider({ children }: any) { setCachedReferralInfoForUser(user) } else { // User logged out; reset to null - deleteAuthCookies() + deleteTokenCookies() setAuthUser(null) localStorage.removeItem(CACHED_USER_KEY) } diff --git a/web/lib/firebase/auth.ts b/web/lib/firebase/auth.ts index b6daea6e..b363189c 100644 --- a/web/lib/firebase/auth.ts +++ b/web/lib/firebase/auth.ts @@ -2,53 +2,73 @@ import { PROJECT_ID } from 'common/envs/constants' import { setCookie, getCookies } from '../util/cookie' import { IncomingMessage, ServerResponse } from 'http' -const TOKEN_KINDS = ['refresh', 'id'] as const -type TokenKind = typeof TOKEN_KINDS[number] +const ONE_HOUR_SECS = 60 * 60 +const TEN_YEARS_SECS = 60 * 60 * 24 * 365 * 10 +const TOKEN_KINDS = ['refresh', 'id', 'custom'] as const +const TOKEN_AGES = { + id: ONE_HOUR_SECS, + refresh: ONE_HOUR_SECS, + custom: TEN_YEARS_SECS, +} as const +export type TokenKind = typeof TOKEN_KINDS[number] const getAuthCookieName = (kind: TokenKind) => { const suffix = `${PROJECT_ID}_${kind}`.toUpperCase().replace(/-/g, '_') return `FIREBASE_TOKEN_${suffix}` } -const ID_COOKIE_NAME = getAuthCookieName('id') -const REFRESH_COOKIE_NAME = getAuthCookieName('refresh') +const COOKIE_NAMES = Object.fromEntries( + TOKEN_KINDS.map((k) => [k, getAuthCookieName(k)]) +) as Record<TokenKind, string> -export const getAuthCookies = (request?: IncomingMessage) => { - const data = request != null ? request.headers.cookie ?? '' : document.cookie - const cookies = getCookies(data) - return { - idToken: cookies[ID_COOKIE_NAME] as string | undefined, - refreshToken: cookies[REFRESH_COOKIE_NAME] as string | undefined, - } -} - -export const setAuthCookies = ( - idToken?: string, - refreshToken?: string, - response?: ServerResponse -) => { - // these tokens last an hour - const idMaxAge = idToken != null ? 60 * 60 : 0 - const idCookie = setCookie(ID_COOKIE_NAME, idToken ?? '', [ - ['path', '/'], - ['max-age', idMaxAge.toString()], - ['samesite', 'lax'], - ['secure'], - ]) - // these tokens don't expire - const refreshMaxAge = refreshToken != null ? 60 * 60 * 24 * 365 * 10 : 0 - const refreshCookie = setCookie(REFRESH_COOKIE_NAME, refreshToken ?? '', [ - ['path', '/'], - ['max-age', refreshMaxAge.toString()], - ['samesite', 'lax'], - ['secure'], - ]) - if (response != null) { - response.setHeader('Set-Cookie', [idCookie, refreshCookie]) +const getCookieDataIsomorphic = (req?: IncomingMessage) => { + if (req != null) { + return req.headers.cookie ?? '' + } else if (document != null) { + return document.cookie } else { - document.cookie = idCookie - document.cookie = refreshCookie + throw new Error( + 'Neither request nor document is available; no way to get cookies.' + ) } } -export const deleteAuthCookies = () => setAuthCookies() +const setCookieDataIsomorphic = (cookies: string[], res?: ServerResponse) => { + if (res != null) { + res.setHeader('Set-Cookie', cookies) + } else if (document != null) { + for (const ck of cookies) { + document.cookie = ck + } + } else { + throw new Error( + 'Neither response nor document is available; no way to set cookies.' + ) + } +} + +export const getTokensFromCookies = (req?: IncomingMessage) => { + const cookies = getCookies(getCookieDataIsomorphic(req)) + return Object.fromEntries( + TOKEN_KINDS.map((k) => [k, cookies[COOKIE_NAMES[k]]]) + ) as Partial<Record<TokenKind, string>> +} + +export const setTokenCookies = ( + cookies: Partial<Record<TokenKind, string | undefined>>, + res?: ServerResponse +) => { + const data = TOKEN_KINDS.filter((k) => k in cookies).map((k) => { + const maxAge = cookies[k] ? TOKEN_AGES[k as TokenKind] : 0 + return setCookie(COOKIE_NAMES[k], cookies[k] ?? '', [ + ['path', '/'], + ['max-age', maxAge.toString()], + ['samesite', 'lax'], + ['secure'], + ]) + }) + setCookieDataIsomorphic(data, res) +} + +export const deleteTokenCookies = (res?: ServerResponse) => + setTokenCookies({ id: undefined, refresh: undefined, custom: undefined }, res) diff --git a/web/lib/firebase/server-auth.ts b/web/lib/firebase/server-auth.ts index 47eadb45..b0d225f1 100644 --- a/web/lib/firebase/server-auth.ts +++ b/web/lib/firebase/server-auth.ts @@ -1,9 +1,25 @@ -import * as admin from 'firebase-admin' import fetch from 'node-fetch' import { IncomingMessage, ServerResponse } from 'http' import { FIREBASE_CONFIG, PROJECT_ID } from 'common/envs/constants' -import { getAuthCookies, setAuthCookies } from './auth' -import { GetServerSideProps, GetServerSidePropsContext } from 'next' +import { getFunctionUrl } from 'common/api' +import { UserCredential } from 'firebase/auth' +import { + getTokensFromCookies, + setTokenCookies, + deleteTokenCookies, +} from './auth' +import { + GetServerSideProps, + GetServerSidePropsContext, + GetServerSidePropsResult, +} from 'next' + +// server firebase SDK +import * as admin from 'firebase-admin' + +// client firebase SDK +import { app as clientApp } from './init' +import { getAuth, signInWithCustomToken } from 'firebase/auth' const ensureApp = async () => { // Note: firebase-admin can only be imported from a server context, @@ -33,7 +49,21 @@ const requestFirebaseIdToken = async (refreshToken: string) => { if (!result.ok) { throw new Error(`Could not refresh ID token: ${await result.text()}`) } - return (await result.json()) as any + return (await result.json()) as { id_token: string; refresh_token: string } +} + +const requestManifoldCustomToken = async (idToken: string) => { + const functionUrl = getFunctionUrl('getcustomtoken') + const result = await fetch(functionUrl, { + method: 'GET', + headers: { + Authorization: `Bearer ${idToken}`, + }, + }) + if (!result.ok) { + throw new Error(`Could not get custom token: ${await result.text()}`) + } + return (await result.json()) as { token: string } } type RequestContext = { @@ -41,39 +71,103 @@ type RequestContext = { res: ServerResponse } -export const getServerAuthenticatedUid = async (ctx: RequestContext) => { - const app = await ensureApp() - const auth = app.auth() - const { idToken, refreshToken } = getAuthCookies(ctx.req) +const authAndRefreshTokens = async (ctx: RequestContext) => { + const adminAuth = (await ensureApp()).auth() + const clientAuth = getAuth(clientApp) + let { id, refresh, custom } = getTokensFromCookies(ctx.req) - // If we have a valid ID token, verify the user immediately with no network trips. - // If the ID token doesn't verify, we'll have to refresh it to see who they are. - // If they don't have any tokens, then we have no idea who they are. - if (idToken != null) { + // step 0: if you have no refresh token you are logged out + if (refresh == null) { + return undefined + } + + // step 1: given a valid refresh token, ensure a valid ID token + if (id != null) { + // if they have an ID token, throw it out if it's invalid/expired try { - return (await auth.verifyIdToken(idToken))?.uid + await adminAuth.verifyIdToken(id) } catch { - // plausibly expired; try the refresh token, if it's present + id = undefined } } - if (refreshToken != null) { + if (id == null) { + // ask for a new one from google using the refresh token try { - const resp = await requestFirebaseIdToken(refreshToken) - setAuthCookies(resp.id_token, resp.refresh_token, ctx.res) - return (await auth.verifyIdToken(resp.id_token))?.uid + const resp = await requestFirebaseIdToken(refresh) + id = resp.id_token + refresh = resp.refresh_token } catch (e) { - // this is a big unexpected problem -- either their cookies are corrupt - // or the refresh token API is down. functionally, they are not logged in + // big unexpected problem -- functionally, they are not logged in console.error(e) + return undefined + } + } + + // step 2: given a valid ID token, ensure a valid custom token, and sign in + // to the client SDK with the custom token + if (custom != null) { + // sign in with this token, or throw it out if it's invalid/expired + try { + return { + creds: await signInWithCustomToken(clientAuth, custom), + id, + refresh, + custom, + } + } catch { + custom = undefined + } + } + if (custom == null) { + // ask for a new one from our cloud functions using the ID token, then sign in + try { + const resp = await requestManifoldCustomToken(id) + custom = resp.token + return { + creds: await signInWithCustomToken(clientAuth, custom), + id, + refresh, + custom, + } + } catch (e) { + // big unexpected problem -- functionally, they are not logged in + console.error(e) + return undefined } } - return undefined } -export const redirectIfLoggedIn = (dest: string, fn?: GetServerSideProps) => { +export const authenticateOnServer = async (ctx: RequestContext) => { + const tokens = await authAndRefreshTokens(ctx) + const creds = tokens?.creds + try { + if (tokens == null) { + deleteTokenCookies(ctx.res) + } else { + setTokenCookies(tokens, ctx.res) + } + } catch (e) { + // definitely not supposed to happen, but let's be maximally robust + console.error(e) + } + return creds +} + +// note that we might want to define these types more generically if we want better +// type safety on next.js stuff... see the definition of GetServerSideProps + +type GetServerSidePropsAuthed<P> = ( + context: GetServerSidePropsContext, + creds: UserCredential +) => Promise<GetServerSidePropsResult<P>> + +export const redirectIfLoggedIn = <P>( + dest: string, + fn?: GetServerSideProps<P> +) => { return async (ctx: GetServerSidePropsContext) => { - const uid = await getServerAuthenticatedUid(ctx) - if (uid == null) { + const creds = await authenticateOnServer(ctx) + if (creds == null) { return fn != null ? await fn(ctx) : { props: {} } } else { return { redirect: { destination: dest, permanent: false } } @@ -81,13 +175,16 @@ export const redirectIfLoggedIn = (dest: string, fn?: GetServerSideProps) => { } } -export const redirectIfLoggedOut = (dest: string, fn?: GetServerSideProps) => { +export const redirectIfLoggedOut = <P>( + dest: string, + fn?: GetServerSidePropsAuthed<P> +) => { return async (ctx: GetServerSidePropsContext) => { - const uid = await getServerAuthenticatedUid(ctx) - if (uid == null) { + const creds = await authenticateOnServer(ctx) + if (creds == null) { return { redirect: { destination: dest, permanent: false } } } else { - return fn != null ? await fn(ctx) : { props: {} } + return fn != null ? await fn(ctx, creds) : { props: {} } } } } diff --git a/web/pages/notifications.tsx b/web/pages/notifications.tsx index 625c7c17..89ffb5d9 100644 --- a/web/pages/notifications.tsx +++ b/web/pages/notifications.tsx @@ -40,10 +40,7 @@ import { track } from '@amplitude/analytics-browser' import { Pagination } from 'web/components/pagination' import { useWindowSize } from 'web/hooks/use-window-size' import { safeLocalStorage } from 'web/lib/util/local' -import { - getServerAuthenticatedUid, - redirectIfLoggedOut, -} from 'web/lib/firebase/server-auth' +import { redirectIfLoggedOut } from 'web/lib/firebase/server-auth' import { SiteLink } from 'web/components/site-link' import { NotificationSettings } from 'web/components/NotificationSettings' @@ -51,12 +48,8 @@ export const NOTIFICATIONS_PER_PAGE = 30 const MULTIPLE_USERS_KEY = 'multipleUsers' const HIGHLIGHT_CLASS = 'bg-indigo-50' -export const getServerSideProps = redirectIfLoggedOut('/', async (ctx) => { - const uid = await getServerAuthenticatedUid(ctx) - if (!uid) { - return { props: { user: null } } - } - const user = await getUser(uid) +export const getServerSideProps = redirectIfLoggedOut('/', async (_, creds) => { + const user = await getUser(creds.user.uid) return { props: { user } } }) From 5892ccee977decd3644c66cfda22db02defa21ca Mon Sep 17 00:00:00 2001 From: Sinclair Chen <abc.sinclair@gmail.com> Date: Sat, 6 Aug 2022 13:39:52 -0700 Subject: [PATCH 71/83] Rich text in comments, fixed (#719) * Revert "Revert "Switch comments/chat to rich text editor (#703)"" This reverts commit 33906adfe489cbc3489bc014c4a3253d96660ec0. * Fix typing after being weird on Android Issue: character from mention gets deleted. Most weird on Swiftkey: mention gets duplicated instead of deleting. See https://github.com/ProseMirror/prosemirror/issues/565 https://bugs.chromium.org/p/chromium/issues/detail?id=612446 The keyboard still closes unexpectedly when you delete :( * On reply, put space instead of \n after mention * Remove image upload from placeholder text - Redundant with image upload button - Too long on mobile * fix dependency --- common/comment.ts | 6 +- functions/src/create-notification.ts | 19 +- functions/src/emails.ts | 4 +- .../src/on-create-comment-on-contract.ts | 11 +- web/components/comments-list.tsx | 7 +- .../contract/contract-leaderboard.tsx | 1 - web/components/editor.tsx | 39 +-- web/components/editor/mention.tsx | 5 +- .../feed/feed-answer-comment-group.tsx | 28 +- web/components/feed/feed-comments.tsx | 246 +++++++----------- web/components/groups/group-chat.tsx | 104 ++++---- web/lib/firebase/comments.ts | 9 +- web/pages/[username]/[contractSlug].tsx | 1 - 13 files changed, 204 insertions(+), 276 deletions(-) diff --git a/common/comment.ts b/common/comment.ts index 0d0c4daf..a217b292 100644 --- a/common/comment.ts +++ b/common/comment.ts @@ -1,3 +1,5 @@ +import type { JSONContent } from '@tiptap/core' + // Currently, comments are created after the bet, not atomically with the bet. // They're uniquely identified by the pair contractId/betId. export type Comment = { @@ -9,7 +11,9 @@ export type Comment = { replyToCommentId?: string userId: string - text: string + /** @deprecated - content now stored as JSON in content*/ + text?: string + content: JSONContent createdTime: number // Denormalized, for rendering comments diff --git a/functions/src/create-notification.ts b/functions/src/create-notification.ts index e16920f7..51b884ad 100644 --- a/functions/src/create-notification.ts +++ b/functions/src/create-notification.ts @@ -7,7 +7,7 @@ import { } from '../../common/notification' import { User } from '../../common/user' import { Contract } from '../../common/contract' -import { getUserByUsername, getValues } from './utils' +import { getValues } from './utils' import { Comment } from '../../common/comment' import { uniq } from 'lodash' import { Bet, LimitBet } from '../../common/bet' @@ -17,6 +17,7 @@ import { removeUndefinedProps } from '../../common/util/object' import { TipTxn } from '../../common/txn' import { Group, GROUP_CHAT_SLUG } from '../../common/group' import { Challenge } from '../../common/challenge' +import { richTextToString } from '../../common/util/parse' const firestore = admin.firestore() type user_to_reason_texts = { @@ -155,17 +156,6 @@ export const createNotification = async ( } } - /** @deprecated parse from rich text instead */ - const parseMentions = async (source: string) => { - const mentions = source.match(/@\w+/g) - if (!mentions) return [] - return Promise.all( - mentions.map( - async (username) => (await getUserByUsername(username.slice(1)))?.id - ) - ) - } - const notifyTaggedUsers = ( userToReasonTexts: user_to_reason_texts, userIds: (string | undefined)[] @@ -301,8 +291,7 @@ export const createNotification = async ( if (sourceType === 'comment') { if (recipients?.[0] && relatedSourceType) notifyRepliedUser(userToReasonTexts, recipients[0], relatedSourceType) - if (sourceText) - notifyTaggedUsers(userToReasonTexts, await parseMentions(sourceText)) + if (sourceText) notifyTaggedUsers(userToReasonTexts, recipients ?? []) } await notifyContractCreator(userToReasonTexts, sourceContract) await notifyOtherAnswerersOnContract(userToReasonTexts, sourceContract) @@ -427,7 +416,7 @@ export const createGroupCommentNotification = async ( sourceUserName: fromUser.name, sourceUserUsername: fromUser.username, sourceUserAvatarUrl: fromUser.avatarUrl, - sourceText: comment.text, + sourceText: richTextToString(comment.content), sourceSlug, sourceTitle: `${group.name}`, isSeenOnHref: sourceSlug, diff --git a/functions/src/emails.ts b/functions/src/emails.ts index b7469e9f..a097393e 100644 --- a/functions/src/emails.ts +++ b/functions/src/emails.ts @@ -17,6 +17,7 @@ import { formatNumericProbability } from '../../common/pseudo-numeric' import { sendTemplateEmail } from './send-email' import { getPrivateUser, getUser } from './utils' import { getFunctionUrl } from '../../common/api' +import { richTextToString } from '../../common/util/parse' const UNSUBSCRIBE_ENDPOINT = getFunctionUrl('unsubscribe') @@ -291,7 +292,8 @@ export const sendNewCommentEmail = async ( const unsubscribeUrl = `${UNSUBSCRIBE_ENDPOINT}?id=${userId}&type=${emailType}` const { name: commentorName, avatarUrl: commentorAvatarUrl } = commentCreator - const { text } = comment + const { content } = comment + const text = richTextToString(content) let betDescription = '' if (bet) { diff --git a/functions/src/on-create-comment-on-contract.ts b/functions/src/on-create-comment-on-contract.ts index 4719fd08..d7aa0c5e 100644 --- a/functions/src/on-create-comment-on-contract.ts +++ b/functions/src/on-create-comment-on-contract.ts @@ -1,13 +1,13 @@ import * as functions from 'firebase-functions' import * as admin from 'firebase-admin' -import { uniq } from 'lodash' - +import { compact, uniq } from 'lodash' import { getContract, getUser, getValues } from './utils' import { Comment } from '../../common/comment' import { sendNewCommentEmail } from './emails' import { Bet } from '../../common/bet' import { Answer } from '../../common/answer' import { createNotification } from './create-notification' +import { parseMentions, richTextToString } from '../../common/util/parse' const firestore = admin.firestore() @@ -71,7 +71,10 @@ export const onCreateCommentOnContract = functions const repliedUserId = comment.replyToCommentId ? comments.find((c) => c.id === comment.replyToCommentId)?.userId : answer?.userId - const recipients = repliedUserId ? [repliedUserId] : [] + + const recipients = uniq( + compact([...parseMentions(comment.content), repliedUserId]) + ) await createNotification( comment.id, @@ -79,7 +82,7 @@ export const onCreateCommentOnContract = functions 'created', commentCreator, eventId, - comment.text, + richTextToString(comment.content), { contract, relatedSourceType, recipients } ) diff --git a/web/components/comments-list.tsx b/web/components/comments-list.tsx index f8e1d7e1..2a467f6d 100644 --- a/web/components/comments-list.tsx +++ b/web/components/comments-list.tsx @@ -8,8 +8,8 @@ import { RelativeTimestamp } from './relative-timestamp' import { UserLink } from './user-page' import { User } from 'common/user' import { Col } from './layout/col' -import { Linkify } from './linkify' import { groupBy } from 'lodash' +import { Content } from './editor' export function UserCommentsList(props: { user: User @@ -50,7 +50,8 @@ export function UserCommentsList(props: { function ProfileComment(props: { comment: Comment; className?: string }) { const { comment, className } = props - const { text, userUsername, userName, userAvatarUrl, createdTime } = comment + const { text, content, userUsername, userName, userAvatarUrl, createdTime } = + comment // TODO: find and attach relevant bets by comment betId at some point return ( <Row className={className}> @@ -64,7 +65,7 @@ function ProfileComment(props: { comment: Comment; className?: string }) { />{' '} <RelativeTimestamp time={createdTime} /> </p> - <Linkify text={text} /> + <Content content={content || text} /> </div> </Row> ) diff --git a/web/components/contract/contract-leaderboard.tsx b/web/components/contract/contract-leaderboard.tsx index deb9b857..6f1a778d 100644 --- a/web/components/contract/contract-leaderboard.tsx +++ b/web/components/contract/contract-leaderboard.tsx @@ -107,7 +107,6 @@ export function ContractTopTrades(props: { comment={commentsById[topCommentId]} tips={tips[topCommentId]} betsBySameUser={[betsById[topCommentId]]} - truncate={false} smallAvatar={false} /> </div> diff --git a/web/components/editor.tsx b/web/components/editor.tsx index 963cea7e..2371bbf8 100644 --- a/web/components/editor.tsx +++ b/web/components/editor.tsx @@ -41,14 +41,16 @@ export function useTextEditor(props: { max?: number defaultValue?: Content disabled?: boolean + simple?: boolean }) { - const { placeholder, max, defaultValue = '', disabled } = props + const { placeholder, max, defaultValue = '', disabled, simple } = props const users = useUsers() const editorClass = clsx( proseClass, - 'min-h-[6em] resize-none outline-none border-none pt-3 px-4 focus:ring-0' + !simple && 'min-h-[6em]', + 'outline-none pt-2 px-4' ) const editor = useEditor( @@ -56,7 +58,8 @@ export function useTextEditor(props: { editorProps: { attributes: { class: editorClass } }, extensions: [ StarterKit.configure({ - heading: { levels: [1, 2, 3] }, + heading: simple ? false : { levels: [1, 2, 3] }, + horizontalRule: simple ? false : {}, }), Placeholder.configure({ placeholder, @@ -120,8 +123,9 @@ function isValidIframe(text: string) { export function TextEditor(props: { editor: Editor | null upload: ReturnType<typeof useUploadMutation> + children?: React.ReactNode // additional toolbar buttons }) { - const { editor, upload } = props + const { editor, upload, children } = props const [iframeOpen, setIframeOpen] = useState(false) return ( @@ -133,30 +137,13 @@ export function TextEditor(props: { editor={editor} className={clsx(proseClass, '-ml-2 mr-2 w-full text-slate-300 ')} > - Type <em>*markdown*</em>. Paste or{' '} - <FileUploadButton - className="link text-blue-300" - onFiles={upload.mutate} - > - upload - </FileUploadButton>{' '} - images! + Type <em>*markdown*</em> </FloatingMenu> )} - <div className="overflow-hidden rounded-lg border border-gray-300 shadow-sm focus-within:border-indigo-500 focus-within:ring-1 focus-within:ring-indigo-500"> + <div className="rounded-lg border border-gray-300 shadow-sm focus-within:border-indigo-500 focus-within:ring-1 focus-within:ring-indigo-500"> <EditorContent editor={editor} /> - {/* Spacer element to match the height of the toolbar */} - <div className="py-2" aria-hidden="true"> - {/* Matches height of button in toolbar (1px border + 36px content height) */} - <div className="py-px"> - <div className="h-9" /> - </div> - </div> - </div> - - {/* Toolbar, with buttons for image and embeds */} - <div className="absolute inset-x-0 bottom-0 flex justify-between py-2 pl-3 pr-2"> - <div className="flex items-center space-x-5"> + {/* Toolbar, with buttons for images and embeds */} + <div className="flex h-9 items-center gap-5 pl-4 pr-1"> <div className="flex items-center"> <FileUploadButton onFiles={upload.mutate} @@ -181,6 +168,8 @@ export function TextEditor(props: { <span className="sr-only">Embed an iframe</span> </button> </div> + <div className="ml-auto" /> + {children} </div> </div> </div> diff --git a/web/components/editor/mention.tsx b/web/components/editor/mention.tsx index 3ad5de39..5ccea6f5 100644 --- a/web/components/editor/mention.tsx +++ b/web/components/editor/mention.tsx @@ -11,7 +11,7 @@ const name = 'mention-component' const MentionComponent = (props: any) => { return ( - <NodeViewWrapper className={clsx(name, 'not-prose inline text-indigo-700')}> + <NodeViewWrapper className={clsx(name, 'not-prose text-indigo-700')}> <Linkify text={'@' + props.node.attrs.label} /> </NodeViewWrapper> ) @@ -25,5 +25,6 @@ const MentionComponent = (props: any) => { export const DisplayMention = Mention.extend({ parseHTML: () => [{ tag: name }], renderHTML: ({ HTMLAttributes }) => [name, mergeAttributes(HTMLAttributes)], - addNodeView: () => ReactNodeViewRenderer(MentionComponent), + addNodeView: () => + ReactNodeViewRenderer(MentionComponent, { className: 'inline-block' }), }) diff --git a/web/components/feed/feed-answer-comment-group.tsx b/web/components/feed/feed-answer-comment-group.tsx index aabb1081..edaf1fe5 100644 --- a/web/components/feed/feed-answer-comment-group.tsx +++ b/web/components/feed/feed-answer-comment-group.tsx @@ -31,9 +31,9 @@ export function FeedAnswerCommentGroup(props: { const { answer, contract, comments, tips, bets, user } = props const { username, avatarUrl, name, text } = answer - const [replyToUsername, setReplyToUsername] = useState('') + const [replyToUser, setReplyToUser] = + useState<Pick<User, 'id' | 'username'>>() const [showReply, setShowReply] = useState(false) - const [inputRef, setInputRef] = useState<HTMLTextAreaElement | null>(null) const [highlighted, setHighlighted] = useState(false) const router = useRouter() @@ -70,9 +70,14 @@ export function FeedAnswerCommentGroup(props: { const scrollAndOpenReplyInput = useEvent( (comment?: Comment, answer?: Answer) => { - setReplyToUsername(comment?.userUsername ?? answer?.username ?? '') + setReplyToUser( + comment + ? { id: comment.userId, username: comment.userUsername } + : answer + ? { id: answer.userId, username: answer.username } + : undefined + ) setShowReply(true) - inputRef?.focus() } ) @@ -80,7 +85,7 @@ export function FeedAnswerCommentGroup(props: { // Only show one comment input for a bet at a time if ( betsByCurrentUser.length > 1 && - inputRef?.textContent?.length === 0 && + // inputRef?.textContent?.length === 0 && //TODO: editor.isEmpty betsByCurrentUser.sort((a, b) => b.createdTime - a.createdTime)[0] ?.outcome !== answer.number.toString() ) @@ -89,10 +94,6 @@ export function FeedAnswerCommentGroup(props: { // eslint-disable-next-line react-hooks/exhaustive-deps }, [betsByCurrentUser.length, user, answer.number]) - useEffect(() => { - if (showReply && inputRef) inputRef.focus() - }, [inputRef, showReply]) - useEffect(() => { if (router.asPath.endsWith(`#${answerElementId}`)) { setHighlighted(true) @@ -154,7 +155,6 @@ export function FeedAnswerCommentGroup(props: { commentsList={commentsList} betsByUserId={betsByUserId} smallAvatar={true} - truncate={false} bets={bets} tips={tips} scrollAndOpenReplyInput={scrollAndOpenReplyInput} @@ -172,12 +172,8 @@ export function FeedAnswerCommentGroup(props: { betsByCurrentUser={betsByCurrentUser} commentsByCurrentUser={commentsByCurrentUser} parentAnswerOutcome={answer.number.toString()} - replyToUsername={replyToUsername} - setRef={setInputRef} - onSubmitComment={() => { - setShowReply(false) - setReplyToUsername('') - }} + replyToUser={replyToUser} + onSubmitComment={() => setShowReply(false)} /> </div> )} diff --git a/web/components/feed/feed-comments.tsx b/web/components/feed/feed-comments.tsx index f4c6eb74..8c84039e 100644 --- a/web/components/feed/feed-comments.tsx +++ b/web/components/feed/feed-comments.tsx @@ -13,25 +13,22 @@ import { Avatar } from 'web/components/avatar' import { UserLink } from 'web/components/user-page' import { OutcomeLabel } from 'web/components/outcome-label' import { CopyLinkDateTimeComponent } from 'web/components/feed/copy-link-date-time' -import { contractPath } from 'web/lib/firebase/contracts' import { firebaseLogin } from 'web/lib/firebase/users' import { createCommentOnContract, MAX_COMMENT_LENGTH, } from 'web/lib/firebase/comments' -import Textarea from 'react-expanding-textarea' -import { Linkify } from 'web/components/linkify' -import { SiteLink } from 'web/components/site-link' import { BetStatusText } from 'web/components/feed/feed-bets' import { Col } from 'web/components/layout/col' import { getProbability } from 'common/calculate' import { LoadingIndicator } from 'web/components/loading-indicator' import { PaperAirplaneIcon } from '@heroicons/react/outline' import { track } from 'web/lib/service/analytics' -import { useEvent } from 'web/hooks/use-event' import { Tipper } from '../tipper' import { CommentTipMap, CommentTips } from 'web/hooks/use-tip-txns' import { useWindowSize } from 'web/hooks/use-window-size' +import { Content, TextEditor, useTextEditor } from '../editor' +import { Editor } from '@tiptap/react' export function FeedCommentThread(props: { contract: Contract @@ -39,20 +36,12 @@ export function FeedCommentThread(props: { tips: CommentTipMap parentComment: Comment bets: Bet[] - truncate?: boolean smallAvatar?: boolean }) { - const { - contract, - comments, - bets, - tips, - truncate, - smallAvatar, - parentComment, - } = props + const { contract, comments, bets, tips, smallAvatar, parentComment } = props const [showReply, setShowReply] = useState(false) - const [replyToUsername, setReplyToUsername] = useState('') + const [replyToUser, setReplyToUser] = + useState<{ id: string; username: string }>() const betsByUserId = groupBy(bets, (bet) => bet.userId) const user = useUser() const commentsList = comments.filter( @@ -60,15 +49,12 @@ export function FeedCommentThread(props: { parentComment.id && comment.replyToCommentId === parentComment.id ) commentsList.unshift(parentComment) - const [inputRef, setInputRef] = useState<HTMLTextAreaElement | null>(null) + function scrollAndOpenReplyInput(comment: Comment) { - setReplyToUsername(comment.userUsername) + setReplyToUser({ id: comment.userId, username: comment.userUsername }) setShowReply(true) - inputRef?.focus() } - useEffect(() => { - if (showReply && inputRef) inputRef.focus() - }, [inputRef, showReply]) + return ( <Col className={'w-full gap-3 pr-1'}> <span @@ -81,7 +67,6 @@ export function FeedCommentThread(props: { betsByUserId={betsByUserId} tips={tips} smallAvatar={smallAvatar} - truncate={truncate} bets={bets} scrollAndOpenReplyInput={scrollAndOpenReplyInput} /> @@ -98,13 +83,9 @@ export function FeedCommentThread(props: { (c) => c.userId === user?.id )} parentCommentId={parentComment.id} - replyToUsername={replyToUsername} + replyToUser={replyToUser} parentAnswerOutcome={comments[0].answerOutcome} - setRef={setInputRef} - onSubmitComment={() => { - setShowReply(false) - setReplyToUsername('') - }} + onSubmitComment={() => setShowReply(false)} /> </Col> )} @@ -121,14 +102,12 @@ export function CommentRepliesList(props: { bets: Bet[] treatFirstIndexEqually?: boolean smallAvatar?: boolean - truncate?: boolean }) { const { contract, commentsList, betsByUserId, tips, - truncate, smallAvatar, bets, scrollAndOpenReplyInput, @@ -168,7 +147,6 @@ export function CommentRepliesList(props: { : undefined } smallAvatar={smallAvatar} - truncate={truncate} /> </div> ))} @@ -182,7 +160,6 @@ export function FeedComment(props: { tips: CommentTips betsBySameUser: Bet[] probAtCreatedTime?: number - truncate?: boolean smallAvatar?: boolean onReplyClick?: (comment: Comment) => void }) { @@ -192,10 +169,10 @@ export function FeedComment(props: { tips, betsBySameUser, probAtCreatedTime, - truncate, onReplyClick, } = props - const { text, userUsername, userName, userAvatarUrl, createdTime } = comment + const { text, content, userUsername, userName, userAvatarUrl, createdTime } = + comment let betOutcome: string | undefined, bought: string | undefined, money: string | undefined @@ -276,11 +253,9 @@ export function FeedComment(props: { elementId={comment.id} /> </div> - <TruncatedComment - comment={text} - moreHref={contractPath(contract)} - shouldTruncate={truncate} - /> + <div className="mt-2 text-[15px] text-gray-700"> + <Content content={content || text} /> + </div> <Row className="mt-2 items-center gap-6 text-xs text-gray-500"> <Tipper comment={comment} tips={tips ?? {}} /> {onReplyClick && ( @@ -345,8 +320,7 @@ export function CommentInput(props: { contract: Contract betsByCurrentUser: Bet[] commentsByCurrentUser: Comment[] - replyToUsername?: string - setRef?: (ref: HTMLTextAreaElement) => void + replyToUser?: { id: string; username: string } // Reply to a free response answer parentAnswerOutcome?: string // Reply to another comment @@ -359,12 +333,18 @@ export function CommentInput(props: { commentsByCurrentUser, parentAnswerOutcome, parentCommentId, - replyToUsername, + replyToUser, onSubmitComment, - setRef, } = props const user = useUser() - const [comment, setComment] = useState('') + const { editor, upload } = useTextEditor({ + simple: true, + max: MAX_COMMENT_LENGTH, + placeholder: + !!parentCommentId || !!parentAnswerOutcome + ? 'Write a reply...' + : 'Write a comment...', + }) const [isSubmitting, setIsSubmitting] = useState(false) const mostRecentCommentableBet = getMostRecentCommentableBet( @@ -380,18 +360,17 @@ export function CommentInput(props: { track('sign in to comment') return await firebaseLogin() } - if (!comment || isSubmitting) return + if (!editor || editor.isEmpty || isSubmitting) return setIsSubmitting(true) await createCommentOnContract( contract.id, - comment, + editor.getJSON(), user, betId, parentAnswerOutcome, parentCommentId ) onSubmitComment?.() - setComment('') setIsSubmitting(false) } @@ -446,14 +425,12 @@ export function CommentInput(props: { )} </div> <CommentInputTextArea - commentText={comment} - setComment={setComment} - isReply={!!parentCommentId || !!parentAnswerOutcome} - replyToUsername={replyToUsername ?? ''} + editor={editor} + upload={upload} + replyToUser={replyToUser} user={user} submitComment={submitComment} isSubmitting={isSubmitting} - setRef={setRef} presetId={id} /> </div> @@ -465,94 +442,93 @@ export function CommentInput(props: { export function CommentInputTextArea(props: { user: User | undefined | null - isReply: boolean - replyToUsername: string - commentText: string - setComment: (text: string) => void + replyToUser?: { id: string; username: string } + editor: Editor | null + upload: Parameters<typeof TextEditor>[0]['upload'] submitComment: (id?: string) => void isSubmitting: boolean - setRef?: (ref: HTMLTextAreaElement) => void + submitOnEnter?: boolean presetId?: string - enterToSubmitOnDesktop?: boolean }) { const { - isReply, - setRef, user, - commentText, - setComment, + editor, + upload, submitComment, presetId, isSubmitting, - replyToUsername, - enterToSubmitOnDesktop, + submitOnEnter, + replyToUser, } = props - const { width } = useWindowSize() - const memoizedSetComment = useEvent(setComment) + const isMobile = (useWindowSize().width ?? 0) < 768 // TODO: base off input device (keybord vs touch) + useEffect(() => { - if (!replyToUsername || !user || replyToUsername === user.username) return - const replacement = `@${replyToUsername} ` - memoizedSetComment(replacement + commentText.replace(replacement, '')) + editor?.setEditable(!isSubmitting) + }, [isSubmitting, editor]) + + const submit = () => { + submitComment(presetId) + editor?.commands?.clearContent() + } + + useEffect(() => { + if (!editor) { + return + } + // submit on Enter key + editor.setOptions({ + editorProps: { + handleKeyDown: (view, event) => { + if ( + submitOnEnter && + event.key === 'Enter' && + !event.shiftKey && + (!isMobile || event.ctrlKey || event.metaKey) && + // mention list is closed + !(view.state as any).mention$.active + ) { + submit() + event.preventDefault() + return true + } + return false + }, + }, + }) + // insert at mention and focus + if (replyToUser) { + editor + .chain() + .setContent({ + type: 'mention', + attrs: { label: replyToUser.username, id: replyToUser.id }, + }) + .insertContent(' ') + .focus() + .run() + } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [user, replyToUsername, memoizedSetComment]) + }, [editor]) + return ( <> - <Row className="gap-1.5 text-gray-700"> - <Textarea - ref={setRef} - value={commentText} - onChange={(e) => setComment(e.target.value)} - className={clsx('textarea textarea-bordered w-full resize-none')} - // Make room for floating submit button. - style={{ paddingRight: 48 }} - placeholder={ - isReply - ? 'Write a reply... ' - : enterToSubmitOnDesktop - ? 'Send a message' - : 'Write a comment...' - } - autoFocus={false} - maxLength={MAX_COMMENT_LENGTH} - disabled={isSubmitting} - onKeyDown={(e) => { - if ( - (enterToSubmitOnDesktop && - e.key === 'Enter' && - !e.shiftKey && - width && - width > 768) || - (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) - ) { - e.preventDefault() - submitComment(presetId) - e.currentTarget.blur() - } - }} - /> - - <Col className={clsx('relative justify-end')}> + <div> + <TextEditor editor={editor} upload={upload}> {user && !isSubmitting && ( <button - className={clsx( - 'btn btn-ghost btn-sm absolute right-2 bottom-2 flex-row pl-2 capitalize', - !commentText && 'pointer-events-none text-gray-500' - )} - onClick={() => { - submitComment(presetId) - }} + className="btn btn-ghost btn-sm px-2 disabled:bg-inherit disabled:text-gray-300" + disabled={!editor || editor.isEmpty} + onClick={submit} > - <PaperAirplaneIcon - className={'m-0 min-w-[22px] rotate-90 p-0 '} - height={25} - /> + <PaperAirplaneIcon className="m-0 h-[25px] min-w-[22px] rotate-90 p-0" /> </button> )} + {isSubmitting && ( <LoadingIndicator spinnerClassName={'border-gray-500'} /> )} - </Col> - </Row> + </TextEditor> + </div> <Row> {!user && ( <button @@ -567,38 +543,6 @@ export function CommentInputTextArea(props: { ) } -export function TruncatedComment(props: { - comment: string - moreHref: string - shouldTruncate?: boolean -}) { - const { comment, moreHref, shouldTruncate } = props - let truncated = comment - - // Keep descriptions to at most 400 characters - const MAX_CHARS = 400 - if (shouldTruncate && truncated.length > MAX_CHARS) { - truncated = truncated.slice(0, MAX_CHARS) - // Make sure to end on a space - const i = truncated.lastIndexOf(' ') - truncated = truncated.slice(0, i) - } - - return ( - <div - className="mt-2 whitespace-pre-line break-words text-gray-700" - style={{ fontSize: 15 }} - > - <Linkify text={truncated} /> - {truncated != comment && ( - <SiteLink href={moreHref} className="text-indigo-700"> - ... (show more) - </SiteLink> - )} - </div> - ) -} - function getBettorsLargestPositionBeforeTime( contract: Contract, createdTime: number, diff --git a/web/components/groups/group-chat.tsx b/web/components/groups/group-chat.tsx index 47258b09..2d25351a 100644 --- a/web/components/groups/group-chat.tsx +++ b/web/components/groups/group-chat.tsx @@ -5,24 +5,19 @@ import React, { useEffect, memo, useState, useMemo } from 'react' import { Avatar } from 'web/components/avatar' import { Group } from 'common/group' import { Comment, createCommentOnGroup } from 'web/lib/firebase/comments' -import { - CommentInputTextArea, - TruncatedComment, -} from 'web/components/feed/feed-comments' +import { CommentInputTextArea } from 'web/components/feed/feed-comments' import { track } from 'web/lib/service/analytics' import { firebaseLogin } from 'web/lib/firebase/users' - import { useRouter } from 'next/router' import clsx from 'clsx' import { UserLink } from 'web/components/user-page' - -import { groupPath } from 'web/lib/firebase/groups' import { CopyLinkDateTimeComponent } from 'web/components/feed/copy-link-date-time' import { CommentTipMap, CommentTips } from 'web/hooks/use-tip-txns' import { Tipper } from 'web/components/tipper' import { sum } from 'lodash' import { formatMoney } from 'common/util/format' import { useWindowSize } from 'web/hooks/use-window-size' +import { Content, useTextEditor } from 'web/components/editor' import { useUnseenPreferredNotifications } from 'web/hooks/use-notifications' import { ChevronDownIcon, UsersIcon } from '@heroicons/react/outline' import { setNotificationsAsSeen } from 'web/pages/notifications' @@ -34,16 +29,18 @@ export function GroupChat(props: { tips: CommentTipMap }) { const { messages, user, group, tips } = props - const [messageText, setMessageText] = useState('') + const { editor, upload } = useTextEditor({ + simple: true, + placeholder: 'Send a message', + }) const [isSubmitting, setIsSubmitting] = useState(false) const [scrollToBottomRef, setScrollToBottomRef] = useState<HTMLDivElement | null>(null) const [scrollToMessageId, setScrollToMessageId] = useState('') const [scrollToMessageRef, setScrollToMessageRef] = useState<HTMLDivElement | null>(null) - const [replyToUsername, setReplyToUsername] = useState('') - const [inputRef, setInputRef] = useState<HTMLTextAreaElement | null>(null) - const [groupedMessages, setGroupedMessages] = useState<Comment[]>([]) + const [replyToUser, setReplyToUser] = useState<any>() + const router = useRouter() const isMember = user && group.memberIds.includes(user?.id) @@ -54,25 +51,26 @@ export function GroupChat(props: { const remainingHeight = (height ?? 0) - (containerRef?.offsetTop ?? 0) - bottomBarHeight - useMemo(() => { + // array of groups, where each group is an array of messages that are displayed as one + const groupedMessages = useMemo(() => { // Group messages with createdTime within 2 minutes of each other. - const tempMessages = [] + const tempGrouped: Comment[][] = [] for (let i = 0; i < messages.length; i++) { const message = messages[i] - if (i === 0) tempMessages.push({ ...message }) + if (i === 0) tempGrouped.push([message]) else { const prevMessage = messages[i - 1] const diff = message.createdTime - prevMessage.createdTime const creatorsMatch = message.userId === prevMessage.userId if (diff < 2 * 60 * 1000 && creatorsMatch) { - tempMessages[tempMessages.length - 1].text += `\n${message.text}` + tempGrouped.at(-1)?.push(message) } else { - tempMessages.push({ ...message }) + tempGrouped.push([message]) } } } - setGroupedMessages(tempMessages) + return tempGrouped }, [messages]) useEffect(() => { @@ -94,11 +92,12 @@ export function GroupChat(props: { useEffect(() => { // is mobile? - if (inputRef && width && width > 720) inputRef.focus() - }, [inputRef, width]) + if (width && width > 720) focusInput() + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [width]) function onReplyClick(comment: Comment) { - setReplyToUsername(comment.userUsername) + setReplyToUser({ id: comment.userId, username: comment.userUsername }) } async function submitMessage() { @@ -106,13 +105,16 @@ export function GroupChat(props: { track('sign in to comment') return await firebaseLogin() } - if (!messageText || isSubmitting) return + if (!editor || editor.isEmpty || isSubmitting) return setIsSubmitting(true) - await createCommentOnGroup(group.id, messageText, user) - setMessageText('') + await createCommentOnGroup(group.id, editor.getJSON(), user) + editor.commands.clearContent() setIsSubmitting(false) - setReplyToUsername('') - inputRef?.focus() + setReplyToUser(undefined) + focusInput() + } + function focusInput() { + editor?.commands.focus() } return ( @@ -123,20 +125,20 @@ export function GroupChat(props: { } ref={setScrollToBottomRef} > - {groupedMessages.map((message) => ( + {groupedMessages.map((messages) => ( <GroupMessage user={user} - key={message.id} - comment={message} + key={`group ${messages[0].id}`} + comments={messages} group={group} onReplyClick={onReplyClick} - highlight={message.id === scrollToMessageId} + highlight={messages[0].id === scrollToMessageId} setRef={ - scrollToMessageId === message.id + scrollToMessageId === messages[0].id ? setScrollToMessageRef : undefined } - tips={tips[message.id] ?? {}} + tips={tips[messages[0].id] ?? {}} /> ))} {messages.length === 0 && ( @@ -144,7 +146,7 @@ export function GroupChat(props: { No messages yet. Why not{isMember ? ` ` : ' join and '} <button className={'cursor-pointer font-bold text-gray-700'} - onClick={() => inputRef?.focus()} + onClick={focusInput} > add one? </button> @@ -162,15 +164,13 @@ export function GroupChat(props: { </div> <div className={'flex-1'}> <CommentInputTextArea - commentText={messageText} - setComment={setMessageText} - isReply={false} + editor={editor} + upload={upload} user={user} - replyToUsername={replyToUsername} + replyToUser={replyToUser} submitComment={submitMessage} isSubmitting={isSubmitting} - enterToSubmitOnDesktop={true} - setRef={setInputRef} + submitOnEnter /> </div> </div> @@ -292,16 +292,18 @@ function GroupChatNotificationsIcon(props: { const GroupMessage = memo(function GroupMessage_(props: { user: User | null | undefined - comment: Comment + comments: Comment[] group: Group onReplyClick?: (comment: Comment) => void setRef?: (ref: HTMLDivElement) => void highlight?: boolean tips: CommentTips }) { - const { comment, onReplyClick, group, setRef, highlight, user, tips } = props - const { text, userUsername, userName, userAvatarUrl, createdTime } = comment - const isCreatorsComment = user && comment.userId === user.id + const { comments, onReplyClick, group, setRef, highlight, user, tips } = props + const first = comments[0] + const { id, userUsername, userName, userAvatarUrl, createdTime } = first + + const isCreatorsComment = user && first.userId === user.id return ( <Col ref={setRef} @@ -331,23 +333,21 @@ const GroupMessage = memo(function GroupMessage_(props: { prefix={'group'} slug={group.slug} createdTime={createdTime} - elementId={comment.id} - /> - </Row> - <Row className={'text-black'}> - <TruncatedComment - comment={text} - moreHref={groupPath(group.slug)} - shouldTruncate={false} + elementId={id} /> </Row> + <div className="mt-2 text-black"> + {comments.map((comment) => ( + <Content content={comment.content || comment.text} /> + ))} + </div> <Row> {!isCreatorsComment && onReplyClick && ( <button className={ 'self-start py-1 text-xs font-bold text-gray-500 hover:underline' } - onClick={() => onReplyClick(comment)} + onClick={() => onReplyClick(first)} > Reply </button> @@ -357,7 +357,7 @@ const GroupMessage = memo(function GroupMessage_(props: { {formatMoney(sum(Object.values(tips)))} </span> )} - {!isCreatorsComment && <Tipper comment={comment} tips={tips} />} + {!isCreatorsComment && <Tipper comment={first} tips={tips} />} </Row> </Col> ) diff --git a/web/lib/firebase/comments.ts b/web/lib/firebase/comments.ts index 5775a2bb..e82c6d45 100644 --- a/web/lib/firebase/comments.ts +++ b/web/lib/firebase/comments.ts @@ -14,6 +14,7 @@ import { User } from 'common/user' import { Comment } from 'common/comment' import { removeUndefinedProps } from 'common/util/object' import { track } from '@amplitude/analytics-browser' +import { JSONContent } from '@tiptap/react' export type { Comment } @@ -21,7 +22,7 @@ export const MAX_COMMENT_LENGTH = 10000 export async function createCommentOnContract( contractId: string, - text: string, + content: JSONContent, commenter: User, betId?: string, answerOutcome?: string, @@ -34,7 +35,7 @@ export async function createCommentOnContract( id: ref.id, contractId, userId: commenter.id, - text: text.slice(0, MAX_COMMENT_LENGTH), + content: content, createdTime: Date.now(), userName: commenter.name, userUsername: commenter.username, @@ -53,7 +54,7 @@ export async function createCommentOnContract( } export async function createCommentOnGroup( groupId: string, - text: string, + content: JSONContent, user: User, replyToCommentId?: string ) { @@ -62,7 +63,7 @@ export async function createCommentOnGroup( id: ref.id, groupId, userId: user.id, - text: text.slice(0, MAX_COMMENT_LENGTH), + content: content, createdTime: Date.now(), userName: user.name, userUsername: user.username, diff --git a/web/pages/[username]/[contractSlug].tsx b/web/pages/[username]/[contractSlug].tsx index 0da6c994..5866f899 100644 --- a/web/pages/[username]/[contractSlug].tsx +++ b/web/pages/[username]/[contractSlug].tsx @@ -354,7 +354,6 @@ function ContractTopTrades(props: { comment={commentsById[topCommentId]} tips={tips[topCommentId]} betsBySameUser={[betsById[topCommentId]]} - truncate={false} smallAvatar={false} /> </div> From da977f62a9401b28e56284ec5765322f6239003e Mon Sep 17 00:00:00 2001 From: Sinclair Chen <abc.sinclair@gmail.com> Date: Sat, 6 Aug 2022 15:43:41 -0700 Subject: [PATCH 72/83] Make added text go after img instead of replacing (#725) --- web/components/contract/contract-description.tsx | 10 +++------- web/components/contract/contract-details.tsx | 11 +++++------ web/components/editor/utils.ts | 10 ++++++++++ 3 files changed, 18 insertions(+), 13 deletions(-) create mode 100644 web/components/editor/utils.ts diff --git a/web/components/contract/contract-description.tsx b/web/components/contract/contract-description.tsx index f9db0cd9..4c9b77a2 100644 --- a/web/components/contract/contract-description.tsx +++ b/web/components/contract/contract-description.tsx @@ -13,6 +13,7 @@ import { TextEditor, useTextEditor } from 'web/components/editor' import { Button } from '../button' import { Spacer } from '../layout/spacer' import { Editor, Content as ContentType } from '@tiptap/react' +import { appendToEditor } from '../editor/utils' export function ContractDescription(props: { contract: Contract @@ -94,12 +95,7 @@ function RichEditContract(props: { contract: Contract; isAdmin?: boolean }) { size="xs" onClick={() => { setEditing(true) - editor - ?.chain() - .setContent(contract.description) - .focus('end') - .insertContent(`<p>${editTimestamp()}</p>`) - .run() + appendToEditor(editor, `<p>${editTimestamp()}</p>`) }} > Edit description @@ -131,7 +127,7 @@ function EditQuestion(props: { function joinContent(oldContent: ContentType, newContent: string) { const editor = new Editor({ content: oldContent, extensions: exhibitExts }) - editor.chain().focus('end').insertContent(newContent).run() + appendToEditor(editor, newContent) return editor.getJSON() } diff --git a/web/components/contract/contract-details.tsx b/web/components/contract/contract-details.tsx index 936f5e24..90b5f3d1 100644 --- a/web/components/contract/contract-details.tsx +++ b/web/components/contract/contract-details.tsx @@ -33,6 +33,7 @@ import { Col } from 'web/components/layout/col' import { ContractGroupsList } from 'web/components/groups/contract-groups-list' import { SiteLink } from 'web/components/site-link' import { groupPath } from 'web/lib/firebase/groups' +import { appendToEditor } from '../editor/utils' export type ShowTime = 'resolve-date' | 'close-date' @@ -282,12 +283,10 @@ function EditableCloseDate(props: { const formattedCloseDate = dayjs(newCloseTime).format('YYYY-MM-DD h:mm a') const editor = new Editor({ content, extensions: exhibitExts }) - editor - .chain() - .focus('end') - .insertContent('<br /><br />') - .insertContent(`Close date updated to ${formattedCloseDate}`) - .run() + appendToEditor( + editor, + `<br><p>Close date updated to ${formattedCloseDate}</p>` + ) updateContract(contract.id, { closeTime: newCloseTime, diff --git a/web/components/editor/utils.ts b/web/components/editor/utils.ts new file mode 100644 index 00000000..74af38c5 --- /dev/null +++ b/web/components/editor/utils.ts @@ -0,0 +1,10 @@ +import { Editor, Content } from '@tiptap/react' + +export function appendToEditor(editor: Editor | null, content: Content) { + editor + ?.chain() + .focus('end') + .createParagraphNear() + .insertContent(content) + .run() +} From 1f8aef2891e3e900a4c995358ee950bc1d97257c Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Sat, 6 Aug 2022 17:45:19 -0700 Subject: [PATCH 73/83] Disable challenges for private instances --- common/challenge.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/common/challenge.ts b/common/challenge.ts index 1a227f94..9bac8c08 100644 --- a/common/challenge.ts +++ b/common/challenge.ts @@ -1,3 +1,5 @@ +import { IS_PRIVATE_MANIFOLD } from './envs/constants' + export type Challenge = { // The link to send: https://manifold.markets/challenges/username/market-slug/{slug} // Also functions as the unique id for the link. @@ -60,4 +62,4 @@ export type Acceptance = { createdTime: number } -export const CHALLENGES_ENABLED = true +export const CHALLENGES_ENABLED = !IS_PRIVATE_MANIFOLD From abd344b951ebafc22d701d15f0b6b210ee93dd2c Mon Sep 17 00:00:00 2001 From: Marshall Polaris <marshall@pol.rs> Date: Sat, 6 Aug 2022 19:24:50 -0700 Subject: [PATCH 74/83] Fix a bug with expiration of refresh and custom tokens --- web/lib/firebase/auth.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web/lib/firebase/auth.ts b/web/lib/firebase/auth.ts index b363189c..5363aa08 100644 --- a/web/lib/firebase/auth.ts +++ b/web/lib/firebase/auth.ts @@ -7,8 +7,8 @@ const TEN_YEARS_SECS = 60 * 60 * 24 * 365 * 10 const TOKEN_KINDS = ['refresh', 'id', 'custom'] as const const TOKEN_AGES = { id: ONE_HOUR_SECS, - refresh: ONE_HOUR_SECS, - custom: TEN_YEARS_SECS, + refresh: TEN_YEARS_SECS, + custom: ONE_HOUR_SECS, } as const export type TokenKind = typeof TOKEN_KINDS[number] From 012b67e3c549de14a7658c525ef4b91e6c5277fc Mon Sep 17 00:00:00 2001 From: Austin Chen <akrolsmir@gmail.com> Date: Sun, 7 Aug 2022 09:56:42 -0700 Subject: [PATCH 75/83] Revert "Fix a bug with expiration of refresh and custom tokens" This reverts commit abd344b951ebafc22d701d15f0b6b210ee93dd2c. --- web/lib/firebase/auth.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web/lib/firebase/auth.ts b/web/lib/firebase/auth.ts index 5363aa08..b363189c 100644 --- a/web/lib/firebase/auth.ts +++ b/web/lib/firebase/auth.ts @@ -7,8 +7,8 @@ const TEN_YEARS_SECS = 60 * 60 * 24 * 365 * 10 const TOKEN_KINDS = ['refresh', 'id', 'custom'] as const const TOKEN_AGES = { id: ONE_HOUR_SECS, - refresh: TEN_YEARS_SECS, - custom: ONE_HOUR_SECS, + refresh: ONE_HOUR_SECS, + custom: TEN_YEARS_SECS, } as const export type TokenKind = typeof TOKEN_KINDS[number] From a910e5dc174e431e6722ee783a366217599dd417 Mon Sep 17 00:00:00 2001 From: Austin Chen <akrolsmir@gmail.com> Date: Sun, 7 Aug 2022 09:57:18 -0700 Subject: [PATCH 76/83] Revert "Revert "Fix a bug with expiration of refresh and custom tokens"" This reverts commit 012b67e3c549de14a7658c525ef4b91e6c5277fc. --- web/lib/firebase/auth.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web/lib/firebase/auth.ts b/web/lib/firebase/auth.ts index b363189c..5363aa08 100644 --- a/web/lib/firebase/auth.ts +++ b/web/lib/firebase/auth.ts @@ -7,8 +7,8 @@ const TEN_YEARS_SECS = 60 * 60 * 24 * 365 * 10 const TOKEN_KINDS = ['refresh', 'id', 'custom'] as const const TOKEN_AGES = { id: ONE_HOUR_SECS, - refresh: ONE_HOUR_SECS, - custom: TEN_YEARS_SECS, + refresh: TEN_YEARS_SECS, + custom: ONE_HOUR_SECS, } as const export type TokenKind = typeof TOKEN_KINDS[number] From 8fb3b42ea1c86c4ee6dcd59ac64df61b1e31e1b9 Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Sun, 7 Aug 2022 16:43:53 -0700 Subject: [PATCH 77/83] Default to trending. Fix close date being opposite --- web/pages/contract-search-firestore.tsx | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/web/pages/contract-search-firestore.tsx b/web/pages/contract-search-firestore.tsx index 9a09b101..eb609d26 100644 --- a/web/pages/contract-search-firestore.tsx +++ b/web/pages/contract-search-firestore.tsx @@ -27,7 +27,7 @@ export default function ContractSearchFirestore(props: { const { querySortOptions, additionalFilter } = props const { initialSort, initialQuery } = useInitialQueryAndSort(querySortOptions) - const [sort, setSort] = useState(initialSort || 'newest') + const [sort, setSort] = useState(initialSort ?? 'score') const [query, setQuery] = useState(initialQuery) let matches = (contracts ?? []).filter((c) => @@ -48,11 +48,7 @@ export default function ContractSearchFirestore(props: { matches.sort((a, b) => a.createdTime - b.createdTime) } else if (sort === 'close-date') { matches = sortBy(matches, ({ volume24Hours }) => -1 * volume24Hours) - matches = sortBy( - matches, - (contract) => - (sort === 'close-date' ? -1 : 1) * (contract.closeTime ?? Infinity) - ) + matches = sortBy(matches, (contract) => contract.closeTime ?? Infinity) } else if (sort === 'most-traded') { matches.sort((a, b) => b.volume - a.volume) } else if (sort === 'score') { @@ -109,9 +105,8 @@ export default function ContractSearchFirestore(props: { value={sort} onChange={(e) => setSort(e.target.value as Sort)} > - <option value="newest">Newest</option> - <option value="oldest">Oldest</option> <option value="score">Trending</option> + <option value="newest">Newest</option> <option value="most-traded">Most traded</option> <option value="24-hour-vol">24h volume</option> <option value="close-date">Closing soon</option> From 98806a806f7a0657b5d72502d921db1f82037910 Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Sun, 7 Aug 2022 16:50:12 -0700 Subject: [PATCH 78/83] Fix query params on emulator/private instance --- web/hooks/use-sort-and-query-params.tsx | 49 +------------------------ web/pages/contract-search-firestore.tsx | 8 ++-- 2 files changed, 4 insertions(+), 53 deletions(-) diff --git a/web/hooks/use-sort-and-query-params.tsx b/web/hooks/use-sort-and-query-params.tsx index ad009443..c4bce0c0 100644 --- a/web/hooks/use-sort-and-query-params.tsx +++ b/web/hooks/use-sort-and-query-params.tsx @@ -1,4 +1,4 @@ -import { defaults, debounce } from 'lodash' +import { debounce } from 'lodash' import { useRouter } from 'next/router' import { useEffect, useMemo, useState } from 'react' import { DEFAULT_SORT } from 'web/components/contract-search' @@ -25,53 +25,6 @@ export function getSavedSort() { } } -export function useInitialQueryAndSort(options?: { - defaultSort: Sort - shouldLoadFromStorage?: boolean -}) { - const { defaultSort, shouldLoadFromStorage } = defaults(options, { - defaultSort: DEFAULT_SORT, - shouldLoadFromStorage: true, - }) - const router = useRouter() - - const [initialSort, setInitialSort] = useState<Sort | undefined>(undefined) - const [initialQuery, setInitialQuery] = useState('') - - useEffect(() => { - // If there's no sort option, then set the one from localstorage - if (router.isReady) { - const { s: sort, q: query } = router.query as { - q?: string - s?: Sort - } - - setInitialQuery(query ?? '') - - if (!sort && shouldLoadFromStorage) { - console.log('ready loading from storage ', sort ?? defaultSort) - const localSort = getSavedSort() - if (localSort) { - // Use replace to not break navigating back. - router.replace( - { query: { ...router.query, s: localSort } }, - undefined, - { shallow: true } - ) - } - setInitialSort(localSort ?? defaultSort) - } else { - setInitialSort(sort ?? defaultSort) - } - } - }, [defaultSort, router.isReady, shouldLoadFromStorage]) - - return { - initialSort, - initialQuery, - } -} - export function useQueryAndSortParams(options?: { defaultSort?: Sort shouldLoadFromStorage?: boolean diff --git a/web/pages/contract-search-firestore.tsx b/web/pages/contract-search-firestore.tsx index eb609d26..e2ca308c 100644 --- a/web/pages/contract-search-firestore.tsx +++ b/web/pages/contract-search-firestore.tsx @@ -1,12 +1,11 @@ import { Answer } from 'common/answer' import { searchInAny } from 'common/util/parse' import { sortBy } from 'lodash' -import { useState } from 'react' import { ContractsGrid } from 'web/components/contract/contracts-grid' import { useContracts } from 'web/hooks/use-contracts' import { Sort, - useInitialQueryAndSort, + useQueryAndSortParams, } from 'web/hooks/use-sort-and-query-params' const MAX_CONTRACTS_RENDERED = 100 @@ -26,9 +25,8 @@ export default function ContractSearchFirestore(props: { const contracts = useContracts() const { querySortOptions, additionalFilter } = props - const { initialSort, initialQuery } = useInitialQueryAndSort(querySortOptions) - const [sort, setSort] = useState(initialSort ?? 'score') - const [query, setQuery] = useState(initialQuery) + const { query, setQuery, sort, setSort } = + useQueryAndSortParams(querySortOptions) let matches = (contracts ?? []).filter((c) => searchInAny( From 85e55312ca43a353990a1aab9edffdba0180103a Mon Sep 17 00:00:00 2001 From: Marshall Polaris <marshall@pol.rs> Date: Mon, 8 Aug 2022 15:05:25 -0700 Subject: [PATCH 79/83] What will be removed, is removed (#721) --- web/pages/trades.tsx | 10 ---------- 1 file changed, 10 deletions(-) delete mode 100644 web/pages/trades.tsx diff --git a/web/pages/trades.tsx b/web/pages/trades.tsx deleted file mode 100644 index a29fb7f0..00000000 --- a/web/pages/trades.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import Router from 'next/router' -import { redirectIfLoggedOut } from 'web/lib/firebase/server-auth' - -export const getServerSideProps = redirectIfLoggedOut('/') - -// Deprecated: redirects to /portfolio. -// Eventually, this will be removed. -export default function TradesPage() { - Router.replace('/portfolio') -} From fd308151b355e26ee15eec9b280216252232c757 Mon Sep 17 00:00:00 2001 From: Austin Chen <akrolsmir@gmail.com> Date: Mon, 8 Aug 2022 15:24:28 -0700 Subject: [PATCH 80/83] Disable bouncing Challenge --- web/components/contract/share-row.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/web/components/contract/share-row.tsx b/web/components/contract/share-row.tsx index 9c8c1573..9011ff1b 100644 --- a/web/components/contract/share-row.tsx +++ b/web/components/contract/share-row.tsx @@ -52,7 +52,6 @@ export function ShareRow(props: { () => setIsOpen(true), 'click challenge button' )} - className="animate-bounce" > ⚔️ Challenge <CreateChallengeModal From 564916134834fab8a9779a58090bda427bdc7a98 Mon Sep 17 00:00:00 2001 From: Marshall Polaris <marshall@pol.rs> Date: Mon, 8 Aug 2022 22:42:52 -0700 Subject: [PATCH 81/83] Pass page props user to auth provider if present (#724) * Pass page props user to auth provider if present * Rename `user` -> `serverUser` * Don't load from local storage if server told us a user --- web/components/auth-context.tsx | 21 +++++++++++++-------- web/pages/_app.tsx | 2 +- 2 files changed, 14 insertions(+), 9 deletions(-) diff --git a/web/components/auth-context.tsx b/web/components/auth-context.tsx index 24adde25..332c96be 100644 --- a/web/components/auth-context.tsx +++ b/web/components/auth-context.tsx @@ -1,4 +1,4 @@ -import { createContext, useEffect } from 'react' +import { ReactNode, createContext, useEffect } from 'react' import { User } from 'common/user' import { onIdTokenChanged } from 'firebase/auth' import { @@ -28,15 +28,20 @@ const ensureDeviceToken = () => { return deviceToken } -export const AuthContext = createContext<AuthUser>(null) - -export function AuthProvider({ children }: any) { - const [authUser, setAuthUser] = useStateCheckEquality<AuthUser>(undefined) +export const AuthContext = createContext<AuthUser>(undefined) +export function AuthProvider(props: { + children: ReactNode + serverUser?: AuthUser +}) { + const { children, serverUser } = props + const [authUser, setAuthUser] = useStateCheckEquality<AuthUser>(serverUser) useEffect(() => { - const cachedUser = localStorage.getItem(CACHED_USER_KEY) - setAuthUser(cachedUser && JSON.parse(cachedUser)) - }, [setAuthUser]) + if (serverUser === undefined) { + const cachedUser = localStorage.getItem(CACHED_USER_KEY) + setAuthUser(cachedUser && JSON.parse(cachedUser)) + } + }, [setAuthUser, serverUser]) useEffect(() => { return onIdTokenChanged(auth, async (fbUser) => { diff --git a/web/pages/_app.tsx b/web/pages/_app.tsx index 14dd6cf0..42b5e922 100644 --- a/web/pages/_app.tsx +++ b/web/pages/_app.tsx @@ -79,7 +79,7 @@ function MyApp({ Component, pageProps }: AppProps) { content="width=device-width, initial-scale=1, maximum-scale=1" /> </Head> - <AuthProvider> + <AuthProvider serverUser={pageProps.user}> <QueryClientProvider client={queryClient}> <Welcome {...pageProps} /> <Component {...pageProps} /> From e7f1d3924bce982482ede0ee628641ed123085e4 Mon Sep 17 00:00:00 2001 From: Marshall Polaris <marshall@pol.rs> Date: Mon, 8 Aug 2022 22:43:04 -0700 Subject: [PATCH 82/83] Fix up several pages to load user data on the server (#722) * Fix up several pages to load user data on the server * Add key prop to `EditUserField` --- web/lib/firebase/users.ts | 5 ++ web/pages/create.tsx | 27 +++---- web/pages/links.tsx | 19 ++--- web/pages/notifications.tsx | 23 ++---- web/pages/profile.tsx | 146 ++++++++++++++++-------------------- 5 files changed, 101 insertions(+), 119 deletions(-) diff --git a/web/lib/firebase/users.ts b/web/lib/firebase/users.ts index 5e00affe..3096f00f 100644 --- a/web/lib/firebase/users.ts +++ b/web/lib/firebase/users.ts @@ -52,6 +52,11 @@ export async function getUser(userId: string) { return (await getDoc(doc(users, userId))).data()! } +export async function getPrivateUser(userId: string) { + /* eslint-disable-next-line @typescript-eslint/no-non-null-assertion */ + return (await getDoc(doc(users, userId))).data()! +} + export async function getUserByUsername(username: string) { // Find a user whose username matches the given username, or null if no such user exists. const q = query(users, where('username', '==', username), limit(1)) diff --git a/web/pages/create.tsx b/web/pages/create.tsx index 642cbaec..19ab2fe0 100644 --- a/web/pages/create.tsx +++ b/web/pages/create.tsx @@ -4,7 +4,7 @@ import clsx from 'clsx' import dayjs from 'dayjs' import Textarea from 'react-expanding-textarea' import { Spacer } from 'web/components/layout/spacer' -import { useUser } from 'web/hooks/use-user' +import { getUser } from 'web/lib/firebase/users' import { Contract, contractPath } from 'web/lib/firebase/contracts' import { createMarket } from 'web/lib/firebase/api' import { FIXED_ANTE } from 'common/antes' @@ -33,7 +33,10 @@ import { Title } from 'web/components/title' import { SEO } from 'web/components/SEO' import { MultipleChoiceAnswers } from 'web/components/answers/multiple-choice-answers' -export const getServerSideProps = redirectIfLoggedOut('/') +export const getServerSideProps = redirectIfLoggedOut('/', async (_, creds) => { + const user = await getUser(creds.user.uid) + return { props: { user } } +}) type NewQuestionParams = { groupId?: string @@ -49,8 +52,9 @@ type NewQuestionParams = { initValue?: string } -export default function Create() { +export default function Create(props: { user: User }) { useTracking('view create page') + const { user } = props const router = useRouter() const params = router.query as NewQuestionParams // TODO: Not sure why Question is pulled out as its own component; @@ -60,8 +64,7 @@ export default function Create() { setQuestion(params.q ?? '') }, [params.q]) - const creator = useUser() - if (!router.isReady || !creator) return <div /> + if (!router.isReady) return <div /> return ( <Page> @@ -93,7 +96,7 @@ export default function Create() { </div> </form> <Spacer h={6} /> - <NewContract question={question} params={params} creator={creator} /> + <NewContract question={question} params={params} creator={user} /> </div> </div> </Page> @@ -102,7 +105,7 @@ export default function Create() { // Allow user to create a new contract export function NewContract(props: { - creator?: User | null + creator: User question: string params?: NewQuestionParams }) { @@ -120,14 +123,14 @@ export function NewContract(props: { const [answers, setAnswers] = useState<string[]>([]) // for multiple choice useEffect(() => { - if (groupId && creator) + if (groupId) getGroup(groupId).then((group) => { if (group && canModifyGroupContracts(group, creator.id)) { setSelectedGroup(group) setShowGroupSelector(false) } }) - }, [creator, groupId]) + }, [creator.id, groupId]) const [ante, _setAnte] = useState(FIXED_ANTE) // If params.closeTime is set, extract out the specified date and time @@ -152,7 +155,7 @@ export function NewContract(props: { ? dayjs(`${closeDate}T${closeHoursMinutes}`).valueOf() : undefined - const balance = creator?.balance || 0 + const balance = creator.balance || 0 const min = minString ? parseFloat(minString) : undefined const max = maxString ? parseFloat(maxString) : undefined @@ -214,7 +217,7 @@ export function NewContract(props: { async function submit() { // TODO: Tell users why their contract is invalid - if (!creator || !isValid) return + if (!isValid) return setIsSubmitting(true) try { const result = await createMarket( @@ -249,8 +252,6 @@ export function NewContract(props: { } } - if (!creator) return <></> - return ( <div> <label className="label"> diff --git a/web/pages/links.tsx b/web/pages/links.tsx index be3015ee..55939b19 100644 --- a/web/pages/links.tsx +++ b/web/pages/links.tsx @@ -11,10 +11,11 @@ import { Page } from 'web/components/page' import { SEO } from 'web/components/SEO' import { Title } from 'web/components/title' import { Subtitle } from 'web/components/subtitle' -import { useUser } from 'web/hooks/use-user' +import { getUser } from 'web/lib/firebase/users' import { useUserManalinks } from 'web/lib/firebase/manalinks' import { useUserById } from 'web/hooks/use-user' import { ManalinkTxn } from 'common/txn' +import { User } from 'common/user' import { Avatar } from 'web/components/avatar' import { RelativeTimestamp } from 'web/components/relative-timestamp' import { UserLink } from 'web/components/user-page' @@ -27,15 +28,19 @@ import { Manalink } from 'common/manalink' import { REFERRAL_AMOUNT } from 'common/user' const LINKS_PER_PAGE = 24 -export const getServerSideProps = redirectIfLoggedOut('/') + +export const getServerSideProps = redirectIfLoggedOut('/', async (_, creds) => { + const user = await getUser(creds.user.uid) + return { props: { user } } +}) export function getManalinkUrl(slug: string) { return `${location.protocol}//${location.host}/link/${slug}` } -export default function LinkPage() { - const user = useUser() - const links = useUserManalinks(user?.id ?? '') +export default function LinkPage(props: { user: User }) { + const { user } = props + const links = useUserManalinks(user.id ?? '') // const manalinkTxns = useManalinkTxns(user?.id ?? '') const [highlightedSlug, setHighlightedSlug] = useState('') const unclaimedLinks = links.filter( @@ -44,10 +49,6 @@ export default function LinkPage() { (l.expiresTime == null || l.expiresTime > Date.now()) ) - if (user == null) { - return null - } - return ( <Page> <SEO diff --git a/web/pages/notifications.tsx b/web/pages/notifications.tsx index 89ffb5d9..69139f9c 100644 --- a/web/pages/notifications.tsx +++ b/web/pages/notifications.tsx @@ -1,5 +1,4 @@ import { Tabs } from 'web/components/layout/tabs' -import { usePrivateUser } from 'web/hooks/use-user' import React, { useEffect, useMemo, useState } from 'react' import { Notification, notification_source_types } from 'common/notification' import { Avatar, EmptyAvatar } from 'web/components/avatar' @@ -13,9 +12,8 @@ import { MANIFOLD_AVATAR_URL, MANIFOLD_USERNAME, PrivateUser, - User, } from 'common/user' -import { getUser } from 'web/lib/firebase/users' +import { getPrivateUser } from 'web/lib/firebase/users' import clsx from 'clsx' import { RelativeTimestamp } from 'web/components/relative-timestamp' import { Linkify } from 'web/components/linkify' @@ -35,7 +33,6 @@ import { formatMoney } from 'common/util/format' import { groupPath } from 'web/lib/firebase/groups' import { UNIQUE_BETTOR_BONUS_AMOUNT } from 'common/numeric-constants' import { groupBy, sum, uniq } from 'lodash' -import Custom404 from 'web/pages/404' import { track } from '@amplitude/analytics-browser' import { Pagination } from 'web/components/pagination' import { useWindowSize } from 'web/hooks/use-window-size' @@ -49,13 +46,12 @@ const MULTIPLE_USERS_KEY = 'multipleUsers' const HIGHLIGHT_CLASS = 'bg-indigo-50' export const getServerSideProps = redirectIfLoggedOut('/', async (_, creds) => { - const user = await getUser(creds.user.uid) - return { props: { user } } + const privateUser = await getPrivateUser(creds.user.uid) + return { props: { privateUser } } }) -export default function Notifications(props: { user: User }) { - const { user } = props - const privateUser = usePrivateUser(user?.id) +export default function Notifications(props: { privateUser: PrivateUser }) { + const { privateUser } = props const local = safeLocalStorage() let localNotifications = [] as Notification[] const localSavedNotificationGroups = local?.getItem('notification-groups') @@ -67,7 +63,6 @@ export default function Notifications(props: { user: User }) { .flat() } - if (!user) return <Custom404 /> return ( <Page> <div className={'px-2 pt-4 sm:px-4 lg:pt-0'}> @@ -81,17 +76,11 @@ export default function Notifications(props: { user: User }) { tabs={[ { title: 'Notifications', - content: privateUser ? ( + content: ( <NotificationsList privateUser={privateUser} cachedNotifications={localNotifications} /> - ) : ( - <div className={'min-h-[100vh]'}> - <RenderNotificationGroups - notificationGroups={localNotificationGroups} - /> - </div> ), }, { diff --git a/web/pages/profile.tsx b/web/pages/profile.tsx index 541f5de9..42bcb5c3 100644 --- a/web/pages/profile.tsx +++ b/web/pages/profile.tsx @@ -1,25 +1,35 @@ -import React, { useEffect, useState } from 'react' +import React, { useState } from 'react' import { RefreshIcon } from '@heroicons/react/outline' import { AddFundsButton } from 'web/components/add-funds-button' import { Page } from 'web/components/page' import { SEO } from 'web/components/SEO' import { Title } from 'web/components/title' -import { usePrivateUser, useUser } from 'web/hooks/use-user' import { formatMoney } from 'common/util/format' import { cleanDisplayName, cleanUsername } from 'common/util/clean-username' import { changeUserInfo } from 'web/lib/firebase/api' import { uploadImage } from 'web/lib/firebase/storage' import { Col } from 'web/components/layout/col' import { Row } from 'web/components/layout/row' -import { User } from 'common/user' -import { updateUser, updatePrivateUser } from 'web/lib/firebase/users' +import { User, PrivateUser } from 'common/user' +import { + getUser, + getPrivateUser, + updateUser, + updatePrivateUser, +} from 'web/lib/firebase/users' import { defaultBannerUrl } from 'web/components/user-page' import { SiteLink } from 'web/components/site-link' import Textarea from 'react-expanding-textarea' import { redirectIfLoggedOut } from 'web/lib/firebase/server-auth' -export const getServerSideProps = redirectIfLoggedOut('/') +export const getServerSideProps = redirectIfLoggedOut('/', async (_, creds) => { + const [user, privateUser] = await Promise.all([ + getUser(creds.user.uid), + getPrivateUser(creds.user.uid), + ]) + return { props: { user, privateUser } } +}) function EditUserField(props: { user: User @@ -58,64 +68,45 @@ function EditUserField(props: { ) } -export default function ProfilePage() { - const user = useUser() - const privateUser = usePrivateUser(user?.id) - - const [avatarUrl, setAvatarUrl] = useState(user?.avatarUrl || '') +export default function ProfilePage(props: { + user: User + privateUser: PrivateUser +}) { + const { user, privateUser } = props + const [avatarUrl, setAvatarUrl] = useState(user.avatarUrl || '') const [avatarLoading, setAvatarLoading] = useState(false) - const [name, setName] = useState(user?.name || '') - const [username, setUsername] = useState(user?.username || '') - const [apiKey, setApiKey] = useState(privateUser?.apiKey || '') - - useEffect(() => { - if (user) { - setAvatarUrl(user.avatarUrl || '') - setName(user.name || '') - setUsername(user.username || '') - } - }, [user]) - - useEffect(() => { - if (privateUser) { - setApiKey(privateUser.apiKey || '') - } - }, [privateUser]) + const [name, setName] = useState(user.name) + const [username, setUsername] = useState(user.username) + const [apiKey, setApiKey] = useState(privateUser.apiKey || '') const updateDisplayName = async () => { const newName = cleanDisplayName(name) - if (newName) { setName(newName) - await changeUserInfo({ name: newName }).catch((_) => - setName(user?.name || '') - ) + await changeUserInfo({ name: newName }).catch((_) => setName(user.name)) } else { - setName(user?.name || '') + setName(user.name) } } const updateUsername = async () => { const newUsername = cleanUsername(username) - if (newUsername) { setUsername(newUsername) await changeUserInfo({ username: newUsername }).catch((_) => - setUsername(user?.username || '') + setUsername(user.username) ) } else { - setUsername(user?.username || '') + setUsername(user.username) } } const updateApiKey = async (e: React.MouseEvent) => { const newApiKey = crypto.randomUUID() - if (user?.id != null) { - setApiKey(newApiKey) - await updatePrivateUser(user.id, { apiKey: newApiKey }).catch(() => { - setApiKey(privateUser?.apiKey || '') - }) - } + setApiKey(newApiKey) + await updatePrivateUser(user.id, { apiKey: newApiKey }).catch(() => { + setApiKey(privateUser.apiKey || '') + }) e.preventDefault() } @@ -124,7 +115,7 @@ export default function ProfilePage() { setAvatarLoading(true) - await uploadImage(user?.username || 'default', file) + await uploadImage(user.username, file) .then(async (url) => { await changeUserInfo({ avatarUrl: url }) setAvatarUrl(url) @@ -132,14 +123,10 @@ export default function ProfilePage() { }) .catch(() => { setAvatarLoading(false) - setAvatarUrl(user?.avatarUrl || '') + setAvatarUrl(user.avatarUrl || '') }) } - if (user == null) { - return <></> - } - return ( <Page> <SEO title="Profile" description="User profile settings" url="/profile" /> @@ -147,7 +134,7 @@ export default function ProfilePage() { <Col className="max-w-lg rounded bg-white p-6 shadow-md sm:mx-auto"> <Row className="justify-between"> <Title className="!mt-0" text="Edit Profile" /> - <SiteLink className="btn btn-primary" href={`/${user?.username}`}> + <SiteLink className="btn btn-primary" href={`/${user.username}`}> Done </SiteLink> </Row> @@ -192,54 +179,53 @@ export default function ProfilePage() { /> </div> - {user && ( - <> - {/* TODO: Allow users with M$ 2000 of assets to set custom banners */} - {/* <EditUserField + {/* TODO: Allow users with M$ 2000 of assets to set custom banners */} + {/* <EditUserField user={user} field="bannerUrl" label="Banner Url" isEditing={isEditing} /> */} - <label className="label"> - Banner image{' '} - <span className="text-sm text-gray-400"> - Not editable for now - </span> - </label> - <div - className="h-32 w-full bg-cover bg-center sm:h-40" - style={{ - backgroundImage: `url(${ - user.bannerUrl || defaultBannerUrl(user.id) - })`, - }} - /> + <label className="label"> + Banner image{' '} + <span className="text-sm text-gray-400">Not editable for now</span> + </label> + <div + className="h-32 w-full bg-cover bg-center sm:h-40" + style={{ + backgroundImage: `url(${ + user.bannerUrl || defaultBannerUrl(user.id) + })`, + }} + /> - {( - [ - ['bio', 'Bio'], - ['website', 'Website URL'], - ['twitterHandle', 'Twitter'], - ['discordHandle', 'Discord'], - ] as const - ).map(([field, label]) => ( - <EditUserField user={user} field={field} label={label} /> - ))} - </> - )} + {( + [ + ['bio', 'Bio'], + ['website', 'Website URL'], + ['twitterHandle', 'Twitter'], + ['discordHandle', 'Discord'], + ] as const + ).map(([field, label]) => ( + <EditUserField + key={field} + user={user} + field={field} + label={label} + /> + ))} <div> <label className="label">Email</label> <div className="ml-1 text-gray-500"> - {privateUser?.email ?? '\u00a0'} + {privateUser.email ?? '\u00a0'} </div> </div> <div> <label className="label">Balance</label> <Row className="ml-1 items-start gap-4 text-gray-500"> - {formatMoney(user?.balance || 0)} + {formatMoney(user.balance)} <AddFundsButton /> </Row> </div> From 592125b5e7eec06af499ef0380c663866a27fec0 Mon Sep 17 00:00:00 2001 From: Marshall Polaris <marshall@pol.rs> Date: Tue, 9 Aug 2022 08:50:11 -0700 Subject: [PATCH 83/83] Fix broken `useBets` filters (#731) --- web/hooks/use-bets.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/web/hooks/use-bets.ts b/web/hooks/use-bets.ts index 38b73dd1..9155d25e 100644 --- a/web/hooks/use-bets.ts +++ b/web/hooks/use-bets.ts @@ -14,21 +14,22 @@ export const useBets = ( options?: { filterChallenges: boolean; filterRedemptions: boolean } ) => { const [bets, setBets] = useState<Bet[] | undefined>() - + const filterChallenges = !!options?.filterChallenges + const filterRedemptions = !!options?.filterRedemptions useEffect(() => { if (contractId) return listenForBets(contractId, (bets) => { - if (options) + if (filterChallenges || filterRedemptions) setBets( bets.filter( (bet) => - (options.filterChallenges ? !bet.challengeSlug : true) && - (options.filterRedemptions ? !bet.isRedemption : true) + (filterChallenges ? !bet.challengeSlug : true) && + (filterRedemptions ? !bet.isRedemption : true) ) ) else setBets(bets) }) - }, [contractId, options]) + }, [contractId, filterChallenges, filterRedemptions]) return bets }