diff --git a/common/contract.ts b/common/contract.ts index 2b330201..5dc4b696 100644 --- a/common/contract.ts +++ b/common/contract.ts @@ -59,6 +59,8 @@ export type Contract = { popularityScore?: number followerCount?: number featuredOnHomeRank?: number + likedByUserIds?: string[] + likedByUserCount?: number } & T export type BinaryContract = Contract & Binary diff --git a/common/group.ts b/common/group.ts index 7d3215ae..181ad153 100644 --- a/common/group.ts +++ b/common/group.ts @@ -10,6 +10,7 @@ export type Group = { anyoneCanJoin: boolean contractIds: string[] + aboutPostId?: string chatDisabled?: boolean mostRecentChatActivityTime?: number mostRecentContractAddedTime?: number diff --git a/common/like.ts b/common/like.ts new file mode 100644 index 00000000..85140e02 --- /dev/null +++ b/common/like.ts @@ -0,0 +1,8 @@ +export type Like = { + id: string // will be id of the object liked, i.e. contract.id + userId: string + type: 'contract' + createdTime: number + tipTxnId?: string +} +export const LIKE_TIP_AMOUNT = 5 diff --git a/common/notification.ts b/common/notification.ts index f10bd3f6..657ea2c1 100644 --- a/common/notification.ts +++ b/common/notification.ts @@ -40,6 +40,8 @@ export type notification_source_types = | 'challenge' | 'betting_streak_bonus' | 'loan' + | 'like' + | 'tip_and_like' export type notification_source_update_types = | 'created' @@ -71,3 +73,5 @@ export type notification_reason_types = | 'betting_streak_incremented' | 'loan_income' | 'you_follow_contract' + | 'liked_your_contract' + | 'liked_and_tipped_your_contract' diff --git a/common/util/object.ts b/common/util/object.ts index 5596286e..41d2cd70 100644 --- a/common/util/object.ts +++ b/common/util/object.ts @@ -1,6 +1,6 @@ import { union } from 'lodash' -export const removeUndefinedProps = (obj: T): T => { +export const removeUndefinedProps = (obj: T): T => { const newObj: any = {} for (const key of Object.keys(obj)) { @@ -37,4 +37,3 @@ export const subtractObjects = ( return newObj as T } - diff --git a/firestore.rules b/firestore.rules index e3514deb..35bff56d 100644 --- a/firestore.rules +++ b/firestore.rules @@ -62,6 +62,11 @@ service cloud.firestore { allow write: if request.auth.uid == userId; } + match /users/{userId}/likes/{likeId} { + allow read; + allow write: if request.auth.uid == userId; + } + match /{somePath=**}/follows/{followUserId} { allow read; } @@ -160,7 +165,7 @@ service cloud.firestore { allow update: if request.auth.uid == resource.data.creatorId && request.resource.data.diff(resource.data) .affectedKeys() - .hasOnly(['name', 'about', 'contractIds', 'memberIds', 'anyoneCanJoin' ]); + .hasOnly(['name', 'about', 'contractIds', 'memberIds', 'anyoneCanJoin', 'aboutPostId' ]); allow update: if (request.auth.uid in resource.data.memberIds || resource.data.anyoneCanJoin) && request.resource.data.diff(resource.data) .affectedKeys() diff --git a/functions/src/create-notification.ts b/functions/src/create-notification.ts index 035126c5..9c5d98c1 100644 --- a/functions/src/create-notification.ts +++ b/functions/src/create-notification.ts @@ -18,6 +18,7 @@ import { TipTxn } from '../../common/txn' import { Group, GROUP_CHAT_SLUG } from '../../common/group' import { Challenge } from '../../common/challenge' import { richTextToString } from '../../common/util/parse' +import { Like } from '../../common/like' const firestore = admin.firestore() type user_to_reason_texts = { @@ -689,3 +690,36 @@ export const createBettingStreakBonusNotification = async ( } return await notificationRef.set(removeUndefinedProps(notification)) } + +export const createLikeNotification = async ( + fromUser: User, + toUser: User, + like: Like, + idempotencyKey: string, + contract: Contract, + tip?: TipTxn +) => { + const notificationRef = firestore + .collection(`/users/${toUser.id}/notifications`) + .doc(idempotencyKey) + const notification: Notification = { + id: idempotencyKey, + userId: toUser.id, + reason: tip ? 'liked_and_tipped_your_contract' : 'liked_your_contract', + createdTime: Date.now(), + isSeen: false, + sourceId: like.id, + sourceType: tip ? 'tip_and_like' : 'like', + sourceUpdateType: 'created', + sourceUserName: fromUser.name, + sourceUserUsername: fromUser.username, + sourceUserAvatarUrl: fromUser.avatarUrl, + sourceText: tip?.amount.toString(), + sourceContractCreatorUsername: contract.creatorUsername, + sourceContractTitle: contract.question, + sourceContractSlug: contract.slug, + sourceSlug: contract.slug, + sourceTitle: contract.question, + } + return await notificationRef.set(removeUndefinedProps(notification)) +} diff --git a/functions/src/index.ts b/functions/src/index.ts index 32bc16c4..6ede39a0 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -31,6 +31,8 @@ export * from './weekly-markets-emails' export * from './reset-betting-streaks' export * from './reset-weekly-emails-flag' export * from './on-update-contract-follow' +export * from './on-create-like' +export * from './on-delete-like' // v2 export * from './health' diff --git a/functions/src/on-create-like.ts b/functions/src/on-create-like.ts new file mode 100644 index 00000000..8c5885b0 --- /dev/null +++ b/functions/src/on-create-like.ts @@ -0,0 +1,71 @@ +import * as functions from 'firebase-functions' +import * as admin from 'firebase-admin' +import { Like } from '../../common/like' +import { getContract, getUser, log } from './utils' +import { createLikeNotification } from './create-notification' +import { TipTxn } from '../../common/txn' +import { uniq } from 'lodash' + +const firestore = admin.firestore() + +export const onCreateLike = functions.firestore + .document('users/{userId}/likes/{likeId}') + .onCreate(async (change, context) => { + const like = change.data() as Like + const { eventId } = context + if (like.type === 'contract') { + await handleCreateLikeNotification(like, eventId) + await updateContractLikes(like) + } + }) + +const updateContractLikes = async (like: Like) => { + const contract = await getContract(like.id) + if (!contract) { + log('Could not find contract') + return + } + const likedByUserIds = uniq(contract.likedByUserIds ?? []) + likedByUserIds.push(like.userId) + await firestore + .collection('contracts') + .doc(like.id) + .update({ likedByUserIds, likedByUserCount: likedByUserIds.length }) +} + +const handleCreateLikeNotification = async (like: Like, eventId: string) => { + const contract = await getContract(like.id) + if (!contract) { + log('Could not find contract') + return + } + const contractCreator = await getUser(contract.creatorId) + if (!contractCreator) { + log('Could not find contract creator') + return + } + const liker = await getUser(like.userId) + if (!liker) { + log('Could not find liker') + return + } + let tipTxnData = undefined + + if (like.tipTxnId) { + const tipTxn = await firestore.collection('txns').doc(like.tipTxnId).get() + if (!tipTxn.exists) { + log('Could not find tip txn') + return + } + tipTxnData = tipTxn.data() as TipTxn + } + + await createLikeNotification( + liker, + contractCreator, + like, + eventId, + contract, + tipTxnData + ) +} diff --git a/functions/src/on-delete-like.ts b/functions/src/on-delete-like.ts new file mode 100644 index 00000000..151614b0 --- /dev/null +++ b/functions/src/on-delete-like.ts @@ -0,0 +1,32 @@ +import * as functions from 'firebase-functions' +import * as admin from 'firebase-admin' +import { Like } from '../../common/like' +import { getContract, log } from './utils' +import { uniq } from 'lodash' + +const firestore = admin.firestore() + +export const onDeleteLike = functions.firestore + .document('users/{userId}/likes/{likeId}') + .onDelete(async (change) => { + const like = change.data() as Like + if (like.type === 'contract') { + await removeContractLike(like) + } + }) + +const removeContractLike = async (like: Like) => { + const contract = await getContract(like.id) + if (!contract) { + log('Could not find contract') + return + } + const likedByUserIds = uniq(contract.likedByUserIds ?? []) + const newLikedByUserIds = likedByUserIds.filter( + (userId) => userId !== like.userId + ) + await firestore.collection('contracts').doc(like.id).update({ + likedByUserIds: newLikedByUserIds, + likedByUserCount: newLikedByUserIds.length, + }) +} diff --git a/functions/src/on-update-contract-follow.ts b/functions/src/on-update-contract-follow.ts index f7d54fe8..20ef8e30 100644 --- a/functions/src/on-update-contract-follow.ts +++ b/functions/src/on-update-contract-follow.ts @@ -2,6 +2,7 @@ import * as functions from 'firebase-functions' import * as admin from 'firebase-admin' import { FieldValue } from 'firebase-admin/firestore' +// TODO: should cache the follower user ids in the contract as these triggers aren't idempotent export const onDeleteContractFollow = functions.firestore .document('contracts/{contractId}/follows/{userId}') .onDelete(async (change, context) => { diff --git a/functions/src/reset-betting-streaks.ts b/functions/src/reset-betting-streaks.ts index 924f5c22..56e450fa 100644 --- a/functions/src/reset-betting-streaks.ts +++ b/functions/src/reset-betting-streaks.ts @@ -28,7 +28,7 @@ const resetBettingStreakForUser = async (user: User) => { const betStreakResetTime = Date.now() - DAY_MS // if they made a bet within the last day, don't reset their streak if ( - (user.lastBetTime ?? 0 > betStreakResetTime) || + (user?.lastBetTime ?? 0) > betStreakResetTime || !user.currentBettingStreak || user.currentBettingStreak === 0 ) diff --git a/package.json b/package.json index e90daf86..dd60d92b 100644 --- a/package.json +++ b/package.json @@ -14,16 +14,16 @@ "dependencies": {}, "devDependencies": { "@types/node": "16.11.11", - "@typescript-eslint/eslint-plugin": "5.25.0", - "@typescript-eslint/parser": "5.25.0", + "@typescript-eslint/eslint-plugin": "5.36.0", + "@typescript-eslint/parser": "5.36.0", "concurrently": "6.5.1", - "eslint": "8.15.0", + "eslint": "8.23.0", "eslint-plugin-lodash": "^7.4.0", "eslint-plugin-unused-imports": "^2.0.0", "nodemon": "2.0.19", - "prettier": "2.5.0", + "prettier": "2.7.1", "ts-node": "10.9.1", - "typescript": "4.6.4" + "typescript": "4.8.2" }, "resolutions": { "@types/react": "17.0.43" diff --git a/web/components/answers/answers-panel.tsx b/web/components/answers/answers-panel.tsx index 6e0bfef6..e53153b1 100644 --- a/web/components/answers/answers-panel.tsx +++ b/web/components/answers/answers-panel.tsx @@ -11,7 +11,6 @@ import { AnswerItem } from './answer-item' import { CreateAnswerPanel } from './create-answer-panel' import { AnswerResolvePanel } from './answer-resolve-panel' import { Spacer } from '../layout/spacer' -import { ActivityItem } from '../feed/activity-items' import { User } from 'common/user' import { getOutcomeProbability } from 'common/calculate' import { Answer } from 'common/answer' @@ -21,9 +20,9 @@ import { Modal } from 'web/components/layout/modal' import { AnswerBetPanel } from 'web/components/answers/answer-bet-panel' import { Row } from 'web/components/layout/row' import { Avatar } from 'web/components/avatar' -import { UserLink } from 'web/components/user-page' import { Linkify } from 'web/components/linkify' import { BuyButton } from 'web/components/yes-no-selector' +import { UserLink } from 'web/components/user-link' export function AnswersPanel(props: { contract: FreeResponseContract | MultipleChoiceContract @@ -176,7 +175,6 @@ function getAnswerItems( type: 'answer' as const, contract, answer, - items: [] as ActivityItem[], user, } }) @@ -186,7 +184,6 @@ function getAnswerItems( function OpenAnswer(props: { contract: FreeResponseContract | MultipleChoiceContract answer: Answer - items: ActivityItem[] type: string }) { const { answer, contract } = props diff --git a/web/components/bets-list.tsx b/web/components/bets-list.tsx index 3270408b..932d689c 100644 --- a/web/components/bets-list.tsx +++ b/web/components/bets-list.tsx @@ -21,7 +21,6 @@ import { getBinaryProbPercent, } from 'web/lib/firebase/contracts' import { Row } from './layout/row' -import { UserLink } from './user-page' import { sellBet } from 'web/lib/firebase/api' import { ConfirmationButton } from './confirmation-button' import { OutcomeLabel, YesLabel, NoLabel } from './outcome-label' @@ -48,6 +47,7 @@ import { LimitBet } from 'common/bet' import { floatingEqual } from 'common/util/math' import { Pagination } from './pagination' import { LimitOrderTable } from './limit-bets' +import { UserLink } from 'web/components/user-link' import { useUserBetContracts } from 'web/hooks/use-contracts' type BetSort = 'newest' | 'profit' | 'closeTime' | 'value' @@ -394,13 +394,11 @@ export function BetsSummary(props: { const { hasShares, invested, profitPercent, payout, profit, totalShares } = getContractBetMetrics(contract, bets) - const excludeSalesAndAntes = bets.filter( - (b) => !b.isAnte && !b.isSold && !b.sale - ) - const yesWinnings = sumBy(excludeSalesAndAntes, (bet) => + const excludeSales = bets.filter((b) => !b.isSold && !b.sale) + const yesWinnings = sumBy(excludeSales, (bet) => calculatePayout(contract, bet, 'YES') ) - const noWinnings = sumBy(excludeSalesAndAntes, (bet) => + const noWinnings = sumBy(excludeSales, (bet) => calculatePayout(contract, bet, 'NO') ) diff --git a/web/components/charity/feed-items.tsx b/web/components/charity/feed-items.tsx index 365aa606..b589f34b 100644 --- a/web/components/charity/feed-items.tsx +++ b/web/components/charity/feed-items.tsx @@ -1,9 +1,9 @@ import { DonationTxn } from 'common/txn' import { Avatar } from '../avatar' import { useUserById } from 'web/hooks/use-user' -import { UserLink } from '../user-page' import { manaToUSD } from '../../../common/util/format' import { RelativeTimestamp } from '../relative-timestamp' +import { UserLink } from 'web/components/user-link' export function Donation(props: { txn: DonationTxn }) { const { txn } = props diff --git a/web/components/comments-list.tsx b/web/components/comments-list.tsx index 12ae0649..0b1c3843 100644 --- a/web/components/comments-list.tsx +++ b/web/components/comments-list.tsx @@ -6,11 +6,11 @@ import { SiteLink } from './site-link' import { Row } from './layout/row' import { Avatar } from './avatar' import { RelativeTimestamp } from './relative-timestamp' -import { UserLink } from './user-page' import { User } from 'common/user' import { Col } from './layout/col' import { Content } from './editor' import { LoadingIndicator } from './loading-indicator' +import { UserLink } from 'web/components/user-link' import { PaginationNextPrev } from 'web/components/pagination' type ContractKey = { diff --git a/web/components/contract-search.tsx b/web/components/contract-search.tsx index 34e1ff0d..fa6ea204 100644 --- a/web/components/contract-search.tsx +++ b/web/components/contract-search.tsx @@ -1,44 +1,35 @@ /* eslint-disable react-hooks/exhaustive-deps */ -import algoliasearch, { SearchIndex } from 'algoliasearch/lite' +import algoliasearch from 'algoliasearch/lite' import { SearchOptions } from '@algolia/client-search' - +import { useRouter } from 'next/router' import { Contract } from 'common/contract' import { User } from 'common/user' -import { Sort, useQuery, useSort } from '../hooks/use-sort-and-query-params' import { ContractHighlightOptions, ContractsGrid, } from './contract/contracts-grid' import { ShowTime } from './contract/contract-details' import { Row } from './layout/row' -import { useEffect, useRef, useMemo, useState } from 'react' -import { unstable_batchedUpdates } from 'react-dom' +import { useEffect, useLayoutEffect, useRef, useMemo } from 'react' import { ENV, IS_PRIVATE_MANIFOLD } from 'common/envs/constants' import { useFollows } from 'web/hooks/use-follows' +import { + storageStore, + historyStore, + urlParamStore, + usePersistentState, +} from 'web/hooks/use-persistent-state' +import { safeLocalStorage } from 'web/lib/util/local' import { track, trackCallback } from 'web/lib/service/analytics' import ContractSearchFirestore from 'web/pages/contract-search-firestore' import { useMemberGroups } from 'web/hooks/use-group' import { NEW_USER_GROUP_SLUGS } from 'common/group' import { PillButton } from './buttons/pill-button' -import { debounce, sortBy } from 'lodash' +import { debounce, isEqual, sortBy } from 'lodash' import { DEFAULT_CATEGORY_GROUPS } from 'common/categories' import { Col } from './layout/col' -import { safeLocalStorage } from 'web/lib/util/local' import clsx from 'clsx' -// TODO: this obviously doesn't work with SSR, common sense would suggest -// that we should save things like this in cookies so the server has them - -const MARKETS_SORT = 'markets_sort' - -function setSavedSort(s: Sort) { - safeLocalStorage()?.setItem(MARKETS_SORT, s) -} - -function getSavedSort() { - return safeLocalStorage()?.getItem(MARKETS_SORT) as Sort | null | undefined -} - const searchClient = algoliasearch( 'GJQPAYENIF', '75c28fc084a80e1129d427d470cf41a3' @@ -47,7 +38,7 @@ const searchClient = algoliasearch( const indexPrefix = ENV === 'DEV' ? 'dev-' : '' const searchIndexName = ENV === 'DEV' ? 'dev-contracts' : 'contractsIndex' -const sortOptions = [ +const SORTS = [ { label: 'Newest', value: 'newest' }, { label: 'Trending', value: 'score' }, { label: 'Most traded', value: 'most-traded' }, @@ -56,16 +47,17 @@ const sortOptions = [ { label: 'Subsidy', value: 'liquidity' }, { label: 'Close date', value: 'close-date' }, { label: 'Resolve date', value: 'resolve-date' }, -] +] as const + +export type Sort = typeof SORTS[number]['value'] type filter = 'personal' | 'open' | 'closed' | 'resolved' | 'all' type SearchParameters = { - index: SearchIndex query: string - numericFilters: SearchOptions['numericFilters'] + sort: Sort + openClosedFilter: 'open' | 'closed' | undefined facetFilters: SearchOptions['facetFilters'] - showTime?: ShowTime } type AdditionalFilter = { @@ -88,8 +80,8 @@ export function ContractSearch(props: { hideQuickBet?: boolean } headerClassName?: string - useQuerySortLocalStorage?: boolean - useQuerySortUrlParams?: boolean + persistPrefix?: string + useQueryUrlParam?: boolean isWholePage?: boolean maxItems?: number noControls?: boolean @@ -104,66 +96,94 @@ export function ContractSearch(props: { cardHideOptions, highlightOptions, headerClassName, - useQuerySortLocalStorage, - useQuerySortUrlParams, + persistPrefix, + useQueryUrlParam, isWholePage, maxItems, noControls, } = props - const [numPages, setNumPages] = useState(1) - const [pages, setPages] = useState([]) - const [showTime, setShowTime] = useState() + const [state, setState] = usePersistentState( + { + numPages: 1, + pages: [] as Contract[][], + showTime: null as ShowTime | null, + }, + !persistPrefix + ? undefined + : { key: `${persistPrefix}-search`, store: historyStore() } + ) - const searchParameters = useRef() + const searchParams = useRef(null) + const searchParamsStore = historyStore() const requestId = useRef(0) + useLayoutEffect(() => { + if (persistPrefix) { + const params = searchParamsStore.get(`${persistPrefix}-params`) + if (params !== undefined) { + searchParams.current = params + } + } + }, []) + + const searchIndex = useMemo( + () => searchClient.initIndex(searchIndexName), + [searchIndexName] + ) + const performQuery = async (freshQuery?: boolean) => { - if (searchParameters.current === undefined) { + if (searchParams.current == null) { return } - const params = searchParameters.current + const { query, sort, openClosedFilter, facetFilters } = searchParams.current const id = ++requestId.current - const requestedPage = freshQuery ? 0 : pages.length - if (freshQuery || requestedPage < numPages) { - const results = await params.index.search(params.query, { - facetFilters: params.facetFilters, - numericFilters: params.numericFilters, + const requestedPage = freshQuery ? 0 : state.pages.length + if (freshQuery || requestedPage < state.numPages) { + const index = query + ? searchIndex + : searchClient.initIndex(`${indexPrefix}contracts-${sort}`) + const numericFilters = query + ? [] + : [ + openClosedFilter === 'open' ? `closeTime > ${Date.now()}` : '', + openClosedFilter === 'closed' ? `closeTime <= ${Date.now()}` : '', + ].filter((f) => f) + const results = await index.search(query, { + facetFilters, + numericFilters, page: requestedPage, hitsPerPage: 20, }) // if there's a more recent request, forget about this one if (id === requestId.current) { const newPage = results.hits as any as Contract[] - // this spooky looking function is the easiest way to get react to - // batch this and not do multiple renders. we can throw it out in react 18. - // see https://github.com/reactwg/react-18/discussions/21 - unstable_batchedUpdates(() => { - setShowTime(params.showTime) - setNumPages(results.nbPages) - if (freshQuery) { - setPages([newPage]) - if (isWholePage) window.scrollTo(0, 0) - } else { - setPages((pages) => [...pages, newPage]) - } - }) + const showTime = + sort === 'close-date' || sort === 'resolve-date' ? sort : null + const pages = freshQuery ? [newPage] : [...state.pages, newPage] + setState({ numPages: results.nbPages, pages, showTime }) + if (freshQuery && isWholePage) window.scrollTo(0, 0) } } } const onSearchParametersChanged = useRef( debounce((params) => { - searchParameters.current = params - performQuery(true) + if (!isEqual(searchParams.current, params)) { + if (persistPrefix) { + searchParamsStore.set(`${persistPrefix}-params`, params) + } + searchParams.current = params + performQuery(true) + } }, 100) ).current - const contracts = pages + const contracts = state.pages .flat() .filter((c) => !additionalFilter?.excludeContractIds?.includes(c.id)) const renderedContracts = - pages.length === 0 ? undefined : contracts.slice(0, maxItems) + state.pages.length === 0 ? undefined : contracts.slice(0, maxItems) if (IS_PRIVATE_MANIFOLD || process.env.NEXT_PUBLIC_FIREBASE_EMULATE) { return @@ -177,8 +197,8 @@ export function ContractSearch(props: { defaultFilter={defaultFilter} additionalFilter={additionalFilter} hideOrderSelector={hideOrderSelector} - useQuerySortLocalStorage={useQuerySortLocalStorage} - useQuerySortUrlParams={useQuerySortUrlParams} + persistPrefix={persistPrefix ? `${persistPrefix}-controls` : undefined} + useQueryUrlParam={useQueryUrlParam} user={user} onSearchParametersChanged={onSearchParametersChanged} noControls={noControls} @@ -186,7 +206,7 @@ export function ContractSearch(props: { void - useQuerySortLocalStorage?: boolean - useQuerySortUrlParams?: boolean + persistPrefix?: string + useQueryUrlParam?: boolean user?: User | null noControls?: boolean }) { @@ -214,25 +234,36 @@ function ContractSearchControls(props: { additionalFilter, hideOrderSelector, onSearchParametersChanged, - useQuerySortLocalStorage, - useQuerySortUrlParams, + persistPrefix, + useQueryUrlParam, user, noControls, } = props - const savedSort = useQuerySortLocalStorage ? getSavedSort() : null - const initialSort = savedSort ?? defaultSort ?? 'score' - const querySortOpts = { useUrl: !!useQuerySortUrlParams } - const [sort, setSort] = useSort(initialSort, querySortOpts) - const [query, setQuery] = useQuery('', querySortOpts) - const [filter, setFilter] = useState(defaultFilter ?? 'open') - const [pillFilter, setPillFilter] = useState(undefined) + const router = useRouter() + const [query, setQuery] = usePersistentState( + '', + !useQueryUrlParam + ? undefined + : { + key: 'q', + store: urlParamStore(router), + } + ) - useEffect(() => { - if (useQuerySortLocalStorage) { - setSavedSort(sort) - } - }, [sort]) + const [state, setState] = usePersistentState( + { + sort: defaultSort ?? 'score', + filter: defaultFilter ?? 'open', + pillFilter: null as string | null, + }, + !persistPrefix + ? undefined + : { + key: `${persistPrefix}-params`, + store: storageStore(safeLocalStorage()), + } + ) const follows = useFollows(user?.id) const memberGroups = (useMemberGroups(user?.id) ?? []).filter( @@ -266,14 +297,16 @@ function ContractSearchControls(props: { ...additionalFilters, additionalFilter ? '' : 'visibility:public', - filter === 'open' ? 'isResolved:false' : '', - filter === 'closed' ? 'isResolved:false' : '', - filter === 'resolved' ? 'isResolved:true' : '', + state.filter === 'open' ? 'isResolved:false' : '', + state.filter === 'closed' ? 'isResolved:false' : '', + state.filter === 'resolved' ? 'isResolved:true' : '', - pillFilter && pillFilter !== 'personal' && pillFilter !== 'your-bets' - ? `groupLinks.slug:${pillFilter}` + state.pillFilter && + state.pillFilter !== 'personal' && + state.pillFilter !== 'your-bets' + ? `groupLinks.slug:${state.pillFilter}` : '', - pillFilter === 'personal' + state.pillFilter === 'personal' ? // Show contracts in groups that the user is a member of memberGroupSlugs .map((slug) => `groupLinks.slug:${slug}`) @@ -285,22 +318,24 @@ function ContractSearchControls(props: { ) : '', // Subtract contracts you bet on from For you. - pillFilter === 'personal' && user ? `uniqueBettorIds:-${user.id}` : '', - pillFilter === 'your-bets' && user + state.pillFilter === 'personal' && user + ? `uniqueBettorIds:-${user.id}` + : '', + state.pillFilter === 'your-bets' && user ? // Show contracts bet on by the user `uniqueBettorIds:${user.id}` : '', ].filter((f) => f) - const numericFilters = query - ? [] - : [ - filter === 'open' ? `closeTime > ${Date.now()}` : '', - filter === 'closed' ? `closeTime <= ${Date.now()}` : '', - ].filter((f) => f) + const openClosedFilter = + state.filter === 'open' + ? 'open' + : state.filter === 'closed' + ? 'closed' + : undefined - const selectPill = (pill: string | undefined) => () => { - setPillFilter(pill) + const selectPill = (pill: string | null) => () => { + setState({ ...state, pillFilter: pill }) track('select search category', { category: pill ?? 'all' }) } @@ -309,34 +344,25 @@ function ContractSearchControls(props: { } const selectFilter = (newFilter: filter) => { - if (newFilter === filter) return - setFilter(newFilter) + if (newFilter === state.filter) return + setState({ ...state, filter: newFilter }) track('select search filter', { filter: newFilter }) } const selectSort = (newSort: Sort) => { - if (newSort === sort) return - setSort(newSort) + if (newSort === state.sort) return + setState({ ...state, sort: newSort }) track('select search sort', { sort: newSort }) } - const indexName = `${indexPrefix}contracts-${sort}` - const index = useMemo(() => searchClient.initIndex(indexName), [indexName]) - const searchIndex = useMemo( - () => searchClient.initIndex(searchIndexName), - [searchIndexName] - ) - useEffect(() => { onSearchParametersChanged({ - index: query ? searchIndex : index, query: query, - numericFilters: numericFilters, + sort: state.sort, + openClosedFilter: openClosedFilter, facetFilters: facetFilters, - showTime: - sort === 'close-date' || sort === 'resolve-date' ? sort : undefined, }) - }, [query, index, searchIndex, filter, JSON.stringify(facetFilters)]) + }, [query, state.sort, openClosedFilter, JSON.stringify(facetFilters)]) if (noControls) { return <> @@ -351,14 +377,14 @@ function ContractSearchControls(props: { type="text" value={query} onChange={(e) => updateQuery(e.target.value)} - onBlur={trackCallback('search', { query })} + onBlur={trackCallback('search', { query: query })} placeholder={'Search'} className="input input-bordered w-full" /> {!query && ( selectSort(e.target.value as Sort)} > - {sortOptions.map((option) => ( + {SORTS.map((option) => ( @@ -386,14 +412,14 @@ function ContractSearchControls(props: { All {user ? 'For you' : 'Featured'} @@ -402,7 +428,7 @@ function ContractSearchControls(props: { {user && ( Your bets @@ -413,7 +439,7 @@ function ContractSearchControls(props: { return ( {name} diff --git a/web/components/contract/contract-details.tsx b/web/components/contract/contract-details.tsx index 175b36b5..72ecbb1f 100644 --- a/web/components/contract/contract-details.tsx +++ b/web/components/contract/contract-details.tsx @@ -12,7 +12,6 @@ import dayjs from 'dayjs' import { Row } from '../layout/row' import { formatMoney } from 'common/util/format' -import { UserLink } from '../user-page' import { Contract, updateContract } from 'web/lib/firebase/contracts' import { DateTimeTooltip } from '../datetime-tooltip' import { fromNow } from 'web/lib/util/time' @@ -34,6 +33,7 @@ import { groupPath } from 'web/lib/firebase/groups' import { insertContent } from '../editor/utils' import { contractMetrics } from 'common/contract-details' import { User } from 'common/user' +import { UserLink } from 'web/components/user-link' import { FeaturedContractBadge } from 'web/components/contract/featured-contract-badge' export type ShowTime = 'resolve-date' | 'close-date' diff --git a/web/components/contract/contract-leaderboard.tsx b/web/components/contract/contract-leaderboard.tsx index 77af001e..cc253433 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]]} - smallAvatar={false} />
@@ -123,12 +122,7 @@ export function ContractTopTrades(props: { <> <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} - /> + <FeedBet contract={contract} bet={betsById[topBetId]} /> </div> <div className="mt-2 text-sm text-gray-500"> {topBettor?.name} made {formatMoney(profitById[topBetId] || 0)}! diff --git a/web/components/contract/contract-overview.tsx b/web/components/contract/contract-overview.tsx index 23485179..37639d79 100644 --- a/web/components/contract/contract-overview.tsx +++ b/web/components/contract/contract-overview.tsx @@ -21,6 +21,7 @@ import { ContractDescription } from './contract-description' import { ContractDetails } from './contract-details' import { NumericGraph } from './numeric-graph' import { ShareRow } from './share-row' +import { LikeMarketButton } from 'web/components/contract/like-market-button' export const ContractOverview = (props: { contract: Contract @@ -43,6 +44,13 @@ export const ContractOverview = (props: { <div className="text-2xl text-indigo-700 md:text-3xl"> <Linkify text={question} /> </div> + {(outcomeType === 'FREE_RESPONSE' || + outcomeType === 'MULTIPLE_CHOICE') && + !resolution && ( + <div className={'sm:hidden'}> + <LikeMarketButton contract={contract} user={user} /> + </div> + )} <Row className={'hidden gap-3 xl:flex'}> {isBinary && ( <BinaryResolutionOrChance @@ -72,28 +80,38 @@ export const ContractOverview = (props: { <Row className="items-center justify-between gap-4 xl:hidden"> <BinaryResolutionOrChance contract={contract} /> {tradingAllowed(contract) && ( - <Col> - <BetButton contract={contract as CPMMBinaryContract} /> - {!user && ( - <div className="mt-1 text-center text-sm text-gray-500"> - (with play money!) - </div> - )} - </Col> + <Row> + <div className={'sm:hidden'}> + <LikeMarketButton contract={contract} user={user} /> + </div> + <Col> + <BetButton contract={contract as CPMMBinaryContract} /> + {!user && ( + <div className="mt-1 text-center text-sm text-gray-500"> + (with play money!) + </div> + )} + </Col> + </Row> )} </Row> ) : isPseudoNumeric ? ( <Row className="items-center justify-between gap-4 xl:hidden"> <PseudoNumericResolutionOrExpectation contract={contract} /> {tradingAllowed(contract) && ( - <Col> - <BetButton contract={contract} /> - {!user && ( - <div className="mt-1 text-center text-sm text-gray-500"> - (with play money!) - </div> - )} - </Col> + <Row> + <div className={'sm:hidden'}> + <LikeMarketButton contract={contract} user={user} /> + </div> + <Col> + <BetButton contract={contract} /> + {!user && ( + <div className="mt-1 text-center text-sm text-gray-500"> + (with play money!) + </div> + )} + </Col> + </Row> )} </Row> ) : ( diff --git a/web/components/contract/contract-tabs.tsx b/web/components/contract/contract-tabs.tsx index 9e9f62bf..417de12b 100644 --- a/web/components/contract/contract-tabs.tsx +++ b/web/components/contract/contract-tabs.tsx @@ -1,15 +1,24 @@ import { Bet } from 'common/bet' -import { Contract } from 'common/contract' +import { Contract, CPMMBinaryContract } from 'common/contract' import { ContractComment } from 'common/comment' import { User } from 'common/user' -import { ContractActivity } from '../feed/contract-activity' +import { + ContractCommentsActivity, + ContractBetsActivity, + FreeResponseContractCommentsActivity, +} from '../feed/contract-activity' import { ContractBetsTable, BetsSummary } from '../bets-list' import { Spacer } from '../layout/spacer' import { Tabs } from '../layout/tabs' import { Col } from '../layout/col' +import { tradingAllowed } from 'web/lib/firebase/contracts' import { CommentTipMap } from 'web/hooks/use-tip-txns' +import { useBets } from 'web/hooks/use-bets' import { useComments } from 'web/hooks/use-comments' import { useLiquidity } from 'web/hooks/use-liquidity' +import { BetSignUpPrompt } from '../sign-up-prompt' +import { PlayMoneyDisclaimer } from '../play-money-disclaimer' +import BetButton from '../bet-button' export function ContractTabs(props: { contract: Contract @@ -18,68 +27,69 @@ export function ContractTabs(props: { comments: ContractComment[] tips: CommentTipMap }) { - const { contract, user, bets, tips } = props + const { contract, user, tips } = props const { outcomeType } = contract - const userBets = user && bets.filter((bet) => bet.userId === user.id) + const bets = useBets(contract.id) ?? props.bets + const lps = useLiquidity(contract.id) ?? [] + + const userBets = + user && bets.filter((bet) => !bet.isAnte && bet.userId === user.id) const visibleBets = bets.filter( (bet) => !bet.isAnte && !bet.isRedemption && bet.amount !== 0 ) - - const liquidityProvisions = - useLiquidity(contract.id)?.filter((l) => !l.isAnte && l.amount > 0) ?? [] + const visibleLps = lps.filter((l) => !l.isAnte && l.amount > 0) // Load comments here, so the badge count will be correct const updatedComments = useComments(contract.id) const comments = updatedComments ?? props.comments const betActivity = ( - <ContractActivity + <ContractBetsActivity contract={contract} - bets={bets} - liquidityProvisions={liquidityProvisions} - comments={comments} - tips={tips} - user={user} - mode="bets" - betRowClassName="!mt-0 xl:hidden" + bets={visibleBets} + lps={visibleLps} /> ) - const commentActivity = ( - <> - <ContractActivity - contract={contract} - bets={bets} - liquidityProvisions={liquidityProvisions} - comments={comments} - tips={tips} - user={user} - mode={ - contract.outcomeType === 'FREE_RESPONSE' - ? 'free-response-comment-answer-groups' - : 'comments' - } - betRowClassName="!mt-0 xl:hidden" - /> - {outcomeType === 'FREE_RESPONSE' && ( + const generalBets = outcomeType === 'FREE_RESPONSE' ? [] : visibleBets + const generalComments = comments.filter( + (comment) => + comment.answerOutcome === undefined && + (outcomeType === 'FREE_RESPONSE' ? comment.betId === undefined : true) + ) + + const commentActivity = + outcomeType === 'FREE_RESPONSE' ? ( + <> + <FreeResponseContractCommentsActivity + contract={contract} + bets={visibleBets} + comments={comments} + tips={tips} + user={user} + /> <Col className={'mt-8 flex w-full '}> <div className={'text-md mt-8 mb-2 text-left'}>General Comments</div> <div className={'mb-4 w-full border-b border-gray-200'} /> - <ContractActivity + <ContractCommentsActivity contract={contract} - bets={bets} - liquidityProvisions={liquidityProvisions} - comments={comments} + bets={generalBets} + comments={generalComments} tips={tips} user={user} - mode={'comments'} - betRowClassName="!mt-0 xl:hidden" /> </Col> - )} - </> - ) + </> + ) : ( + <ContractCommentsActivity + contract={contract} + bets={visibleBets} + comments={comments} + tips={tips} + user={user} + /> + ) const yourTrades = ( <div> @@ -96,19 +106,39 @@ export function ContractTabs(props: { ) return ( - <Tabs - currentPageForAnalytics={'contract'} - tabs={[ - { - title: 'Comments', - content: commentActivity, - badge: `${comments.length}`, - }, - { title: 'Bets', content: betActivity, badge: `${visibleBets.length}` }, - ...(!user || !userBets?.length - ? [] - : [{ title: 'Your bets', content: yourTrades }]), - ]} - /> + <> + <Tabs + currentPageForAnalytics={'contract'} + tabs={[ + { + title: 'Comments', + content: commentActivity, + badge: `${comments.length}`, + }, + { + title: 'Bets', + content: betActivity, + badge: `${visibleBets.length}`, + }, + ...(!user || !userBets?.length + ? [] + : [{ title: 'Your bets', content: yourTrades }]), + ]} + /> + {!user ? ( + <Col className="mt-4 max-w-sm items-center xl:hidden"> + <BetSignUpPrompt /> + <PlayMoneyDisclaimer /> + </Col> + ) : ( + outcomeType === 'BINARY' && + tradingAllowed(contract) && ( + <BetButton + contract={contract as CPMMBinaryContract} + className="mb-2 !mt-0 xl:hidden" + /> + ) + )} + </> ) } diff --git a/web/components/contract/like-market-button.tsx b/web/components/contract/like-market-button.tsx new file mode 100644 index 00000000..f4fed287 --- /dev/null +++ b/web/components/contract/like-market-button.tsx @@ -0,0 +1,56 @@ +import { HeartIcon } from '@heroicons/react/outline' +import { Button } from 'web/components/button' +import React from 'react' +import { Contract } from 'common/contract' +import { User } from 'common/user' +import { useUserLikes } from 'web/hooks/use-likes' +import toast from 'react-hot-toast' +import { formatMoney } from 'common/util/format' +import { likeContract, unLikeContract } from 'web/lib/firebase/likes' +import { LIKE_TIP_AMOUNT } from 'common/like' +import clsx from 'clsx' +import { Row } from 'web/components/layout/row' + +export function LikeMarketButton(props: { + contract: Contract + user: User | null | undefined +}) { + const { contract, user } = props + + const likes = useUserLikes(user?.id) + const likedContractIds = likes + ?.filter((l) => l.type === 'contract') + .map((l) => l.id) + if (!user) return <div /> + + const onLike = async () => { + if (likedContractIds?.includes(contract.id)) { + await unLikeContract(user.id, contract.id) + return + } + await likeContract(user, contract) + toast(`You tipped ${contract.creatorName} ${formatMoney(LIKE_TIP_AMOUNT)}!`) + } + + return ( + <Button + size={'lg'} + className={'mb-1'} + color={'gray-white'} + onClick={onLike} + > + <Row className={'gap-0 sm:gap-2'}> + <HeartIcon + className={clsx( + 'h-6 w-6', + likedContractIds?.includes(contract.id) || + (!likes && contract.likedByUserIds?.includes(user.id)) + ? 'fill-red-500 text-red-500' + : '' + )} + /> + <span className={'hidden sm:block'}>Tip</span> + </Row> + </Button> + ) +} diff --git a/web/components/contract/share-row.tsx b/web/components/contract/share-row.tsx index 1af52291..03bd99e6 100644 --- a/web/components/contract/share-row.tsx +++ b/web/components/contract/share-row.tsx @@ -11,6 +11,7 @@ import { CHALLENGES_ENABLED } from 'common/challenge' import { ShareModal } from './share-modal' import { withTracking } from 'web/lib/service/analytics' import { FollowMarketButton } from 'web/components/follow-market-button' +import { LikeMarketButton } from 'web/components/contract/like-market-button' export function ShareRow(props: { contract: Contract @@ -64,6 +65,9 @@ export function ShareRow(props: { </Button> )} <FollowMarketButton contract={contract} user={user} /> + <div className={'hidden sm:block'}> + <LikeMarketButton contract={contract} user={user} /> + </div> </Row> ) } diff --git a/web/components/editor.tsx b/web/components/editor.tsx index 6af58caa..5f056f8b 100644 --- a/web/components/editor.tsx +++ b/web/components/editor.tsx @@ -236,9 +236,10 @@ const useUploadMutation = (editor: Editor | null) => export function RichContent(props: { content: JSONContent | string + className?: string smallImage?: boolean }) { - const { content, smallImage } = props + const { className, content, smallImage } = props const editor = useEditor({ editorProps: { attributes: { class: proseClass } }, extensions: [ @@ -254,19 +255,24 @@ export function RichContent(props: { }) useEffect(() => void editor?.commands?.setContent(content), [editor, content]) - return <EditorContent editor={editor} /> + return <EditorContent className={className} editor={editor} /> } // backwards compatibility: we used to store content as strings export function Content(props: { content: JSONContent | string + className?: string smallImage?: boolean }) { - const { content } = props + const { className, content } = props return typeof content === 'string' ? ( - <div className="whitespace-pre-line font-light leading-relaxed"> - <Linkify text={content} /> - </div> + <Linkify + className={clsx( + className, + 'whitespace-pre-line font-light leading-relaxed' + )} + text={content} + /> ) : ( <RichContent {...props} /> ) diff --git a/web/components/feed/activity-items.ts b/web/components/feed/activity-items.ts deleted file mode 100644 index bcbb6721..00000000 --- a/web/components/feed/activity-items.ts +++ /dev/null @@ -1,237 +0,0 @@ -import { uniq, sortBy } from 'lodash' - -import { Answer } from 'common/answer' -import { Bet } from 'common/bet' -import { getOutcomeProbability } from 'common/calculate' -import { ContractComment } from 'common/comment' -import { Contract, FreeResponseContract } from 'common/contract' -import { User } from 'common/user' -import { CommentTipMap } from 'web/hooks/use-tip-txns' -import { LiquidityProvision } from 'common/liquidity-provision' - -export type ActivityItem = - | DescriptionItem - | QuestionItem - | BetItem - | AnswerGroupItem - | CloseItem - | ResolveItem - | CommentInputItem - | CommentThreadItem - | LiquidityItem - -type BaseActivityItem = { - id: string - contract: Contract -} - -export type CommentInputItem = BaseActivityItem & { - type: 'commentInput' - betsByCurrentUser: Bet[] - commentsByCurrentUser: ContractComment[] -} - -export type DescriptionItem = BaseActivityItem & { - type: 'description' -} - -export type QuestionItem = BaseActivityItem & { - type: 'question' - contractPath?: string -} - -export type BetItem = BaseActivityItem & { - type: 'bet' - bet: Bet - hideOutcome: boolean - smallAvatar: boolean - hideComment?: boolean -} - -export type CommentThreadItem = BaseActivityItem & { - type: 'commentThread' - parentComment: ContractComment - comments: ContractComment[] - tips: CommentTipMap - bets: Bet[] -} - -export type AnswerGroupItem = BaseActivityItem & { - type: 'answergroup' - user: User | undefined | null - answer: Answer - comments: ContractComment[] - tips: CommentTipMap - bets: Bet[] -} - -export type CloseItem = BaseActivityItem & { - type: 'close' -} - -export type ResolveItem = BaseActivityItem & { - type: 'resolve' -} - -export type LiquidityItem = BaseActivityItem & { - type: 'liquidity' - liquidity: LiquidityProvision - hideOutcome: boolean - smallAvatar: boolean - hideComment?: boolean -} - -function getAnswerAndCommentInputGroups( - contract: FreeResponseContract, - bets: Bet[], - comments: ContractComment[], - tips: CommentTipMap, - user: User | undefined | null -) { - let outcomes = uniq(bets.map((bet) => bet.outcome)) - outcomes = sortBy(outcomes, (outcome) => - getOutcomeProbability(contract, outcome) - ) - - const answerGroups = outcomes - .map((outcome) => { - const answer = contract.answers?.find( - (answer) => answer.id === outcome - ) as Answer - - return { - id: outcome, - type: 'answergroup' as const, - contract, - user, - answer, - comments, - tips, - bets, - } - }) - .filter((group) => group.answer) as ActivityItem[] - return answerGroups -} - -function getCommentThreads( - bets: Bet[], - comments: ContractComment[], - tips: CommentTipMap, - contract: Contract -) { - const parentComments = comments.filter((comment) => !comment.replyToCommentId) - - const items = parentComments.map((comment) => ({ - type: 'commentThread' as const, - id: comment.id, - contract: contract, - comments: comments, - parentComment: comment, - bets: bets, - tips, - })) - - return items -} - -function commentIsGeneralComment(comment: ContractComment, contract: Contract) { - return ( - comment.answerOutcome === undefined && - (contract.outcomeType === 'FREE_RESPONSE' - ? comment.betId === undefined - : true) - ) -} - -export function getSpecificContractActivityItems( - contract: Contract, - bets: Bet[], - comments: ContractComment[], - liquidityProvisions: LiquidityProvision[], - tips: CommentTipMap, - user: User | null | undefined, - options: { - mode: 'comments' | 'bets' | 'free-response-comment-answer-groups' - } -) { - const { mode } = options - let items = [] as ActivityItem[] - - switch (mode) { - case 'bets': - // Remove first bet (which is the ante): - if (contract.outcomeType === 'FREE_RESPONSE') bets = bets.slice(1) - items.push( - ...bets.map((bet) => ({ - type: 'bet' as const, - id: bet.id + '-' + bet.isSold, - bet, - contract, - hideOutcome: false, - smallAvatar: false, - hideComment: true, - })) - ) - items.push( - ...liquidityProvisions.map((liquidity) => ({ - type: 'liquidity' as const, - id: liquidity.id, - contract, - liquidity, - hideOutcome: false, - smallAvatar: false, - })) - ) - items = sortBy(items, (item) => - item.type === 'bet' - ? item.bet.createdTime - : item.type === 'liquidity' - ? item.liquidity.createdTime - : undefined - ) - break - - case 'comments': { - const nonFreeResponseComments = comments.filter((comment) => - commentIsGeneralComment(comment, contract) - ) - const nonFreeResponseBets = - contract.outcomeType === 'FREE_RESPONSE' ? [] : bets - items.push( - ...getCommentThreads( - nonFreeResponseBets, - nonFreeResponseComments, - tips, - contract - ) - ) - - items.push({ - type: 'commentInput', - id: 'commentInput', - contract, - betsByCurrentUser: nonFreeResponseBets.filter( - (bet) => bet.userId === user?.id - ), - commentsByCurrentUser: nonFreeResponseComments.filter( - (comment) => comment.userId === user?.id - ), - }) - break - } - case 'free-response-comment-answer-groups': - items.push( - ...getAnswerAndCommentInputGroups( - contract as FreeResponseContract, - bets, - comments, - tips, - user - ) - ) - break - } - - return items.reverse() -} diff --git a/web/components/feed/contract-activity.tsx b/web/components/feed/contract-activity.tsx index 3cc0acb0..744f06aa 100644 --- a/web/components/feed/contract-activity.tsx +++ b/web/components/feed/contract-activity.tsx @@ -1,55 +1,144 @@ -import { Contract } from 'web/lib/firebase/contracts' +import { Contract, FreeResponseContract } from 'common/contract' import { ContractComment } from 'common/comment' +import { Answer } from 'common/answer' import { Bet } from 'common/bet' -import { useBets } from 'web/hooks/use-bets' -import { getSpecificContractActivityItems } from './activity-items' -import { FeedItems } from './feed-items' +import { getOutcomeProbability } from 'common/calculate' +import { FeedBet } from './feed-bets' +import { FeedLiquidity } from './feed-liquidity' +import { FeedAnswerCommentGroup } from './feed-answer-comment-group' +import { FeedCommentThread, CommentInput } from './feed-comments' import { User } from 'common/user' -import { useContractWithPreload } from 'web/hooks/use-contract' import { CommentTipMap } from 'web/hooks/use-tip-txns' import { LiquidityProvision } from 'common/liquidity-provision' +import { groupBy, sortBy, uniq } from 'lodash' +import { Col } from 'web/components/layout/col' -export function ContractActivity(props: { +export function ContractBetsActivity(props: { contract: Contract bets: Bet[] - comments: ContractComment[] - liquidityProvisions: LiquidityProvision[] - tips: CommentTipMap - user: User | null | undefined - mode: 'comments' | 'bets' | 'free-response-comment-answer-groups' - contractPath?: string - className?: string - betRowClassName?: string + lps: LiquidityProvision[] }) { - const { user, mode, tips, className, betRowClassName, liquidityProvisions } = - props + const { contract, bets, lps } = props - const contract = useContractWithPreload(props.contract) ?? props.contract - const comments = props.comments - const updatedBets = useBets(contract.id, { - filterChallenges: false, - filterRedemptions: true, - }) - const bets = (updatedBets ?? props.bets).filter( - (bet) => !bet.isRedemption && bet.amount !== 0 - ) - const items = getSpecificContractActivityItems( - contract, - bets, - comments, - liquidityProvisions, - tips, - user, - { mode } + const items = [ + ...bets.map((bet) => ({ + type: 'bet' as const, + id: bet.id + '-' + bet.isSold, + bet, + })), + ...lps.map((lp) => ({ + type: 'liquidity' as const, + id: lp.id, + lp, + })), + ] + + const sortedItems = sortBy(items, (item) => + item.type === 'bet' + ? -item.bet.createdTime + : item.type === 'liquidity' + ? -item.lp.createdTime + : undefined ) return ( - <FeedItems - contract={contract} - items={items} - className={className} - betRowClassName={betRowClassName} - user={user} - /> + <Col className="gap-4"> + {sortedItems.map((item) => + item.type === 'bet' ? ( + <FeedBet key={item.id} contract={contract} bet={item.bet} /> + ) : ( + <FeedLiquidity key={item.id} liquidity={item.lp} /> + ) + )} + </Col> + ) +} + +export function ContractCommentsActivity(props: { + contract: Contract + bets: Bet[] + comments: ContractComment[] + tips: CommentTipMap + user: User | null | undefined +}) { + const { bets, contract, comments, user, tips } = props + const betsByUserId = groupBy(bets, (bet) => bet.userId) + const commentsByUserId = groupBy(comments, (c) => c.userId) + const commentsByParentId = groupBy(comments, (c) => c.replyToCommentId ?? '_') + const topLevelComments = sortBy( + commentsByParentId['_'] ?? [], + (c) => -c.createdTime + ) + + return ( + <> + <CommentInput + className="mb-5" + contract={contract} + betsByCurrentUser={(user && betsByUserId[user.id]) ?? []} + commentsByCurrentUser={(user && commentsByUserId[user.id]) ?? []} + /> + {topLevelComments.map((parent) => ( + <FeedCommentThread + key={parent.id} + user={user} + contract={contract} + parentComment={parent} + threadComments={commentsByParentId[parent.id] ?? []} + tips={tips} + bets={bets} + betsByUserId={betsByUserId} + commentsByUserId={commentsByUserId} + /> + ))} + </> + ) +} + +export function FreeResponseContractCommentsActivity(props: { + contract: FreeResponseContract + bets: Bet[] + comments: ContractComment[] + tips: CommentTipMap + user: User | null | undefined +}) { + const { bets, contract, comments, user, tips } = props + + let outcomes = uniq(bets.map((bet) => bet.outcome)) + outcomes = sortBy( + outcomes, + (outcome) => -getOutcomeProbability(contract, outcome) + ) + + const answers = outcomes + .map((outcome) => { + return contract.answers.find((answer) => answer.id === outcome) as Answer + }) + .filter((answer) => answer != null) + + const betsByUserId = groupBy(bets, (bet) => bet.userId) + const commentsByUserId = groupBy(comments, (c) => c.userId) + const commentsByOutcome = groupBy(comments, (c) => c.answerOutcome ?? '_') + + return ( + <> + {answers.map((answer) => ( + <div key={answer.id} className={'relative pb-4'}> + <span + className="absolute top-5 left-5 -ml-px h-[calc(100%-2rem)] w-0.5 bg-gray-200" + aria-hidden="true" + /> + <FeedAnswerCommentGroup + contract={contract} + user={user} + answer={answer} + answerComments={commentsByOutcome[answer.number.toString()] ?? []} + tips={tips} + betsByUserId={betsByUserId} + commentsByUserId={commentsByUserId} + /> + </div> + ))} + </> ) } diff --git a/web/components/feed/copy-link-date-time.tsx b/web/components/feed/copy-link-date-time.tsx index c4e69655..d4401b8c 100644 --- a/web/components/feed/copy-link-date-time.tsx +++ b/web/components/feed/copy-link-date-time.tsx @@ -1,5 +1,4 @@ import React, { useState } from 'react' -import { ENV_CONFIG } from 'common/envs/constants' import { copyToClipboard } from 'web/lib/util/copy' import { DateTimeTooltip } from 'web/components/datetime-tooltip' import Link from 'next/link' @@ -21,9 +20,10 @@ export function CopyLinkDateTimeComponent(props: { event: React.MouseEvent<HTMLAnchorElement, MouseEvent> ) { event.preventDefault() - const elementLocation = `https://${ENV_CONFIG.domain}/${prefix}/${slug}#${elementId}` - - copyToClipboard(elementLocation) + const commentUrl = new URL(window.location.href) + commentUrl.pathname = `/${prefix}/${slug}` + commentUrl.hash = elementId + copyToClipboard(commentUrl.toString()) setShowToast(true) setTimeout(() => setShowToast(false), 2000) } diff --git a/web/components/feed/feed-answer-comment-group.tsx b/web/components/feed/feed-answer-comment-group.tsx index 86686f1f..7758ec82 100644 --- a/web/components/feed/feed-answer-comment-group.tsx +++ b/web/components/feed/feed-answer-comment-group.tsx @@ -1,34 +1,44 @@ import { Answer } from 'common/answer' import { Bet } from 'common/bet' +import { FreeResponseContract } from 'common/contract' import { ContractComment } from 'common/comment' import React, { useEffect, useState } from 'react' import { Col } from 'web/components/layout/col' import { Row } from 'web/components/layout/row' import { Avatar } from 'web/components/avatar' -import { UserLink } from 'web/components/user-page' import { Linkify } from 'web/components/linkify' import clsx from 'clsx' import { CommentInput, - CommentRepliesList, + FeedComment, getMostRecentCommentableBet, } from 'web/components/feed/feed-comments' import { CopyLinkDateTimeComponent } from 'web/components/feed/copy-link-date-time' import { useRouter } from 'next/router' -import { groupBy } from 'lodash' +import { Dictionary } from 'lodash' import { User } from 'common/user' import { useEvent } from 'web/hooks/use-event' import { CommentTipMap } from 'web/hooks/use-tip-txns' +import { UserLink } from 'web/components/user-link' export function FeedAnswerCommentGroup(props: { - contract: any + contract: FreeResponseContract user: User | undefined | null answer: Answer - comments: ContractComment[] + answerComments: ContractComment[] tips: CommentTipMap - bets: Bet[] + betsByUserId: Dictionary<Bet[]> + commentsByUserId: Dictionary<ContractComment[]> }) { - const { answer, contract, comments, tips, bets, user } = props + const { + answer, + contract, + answerComments, + tips, + betsByUserId, + commentsByUserId, + user, + } = props const { username, avatarUrl, name, text } = answer const [replyToUser, setReplyToUser] = @@ -38,11 +48,6 @@ export function FeedAnswerCommentGroup(props: { const router = useRouter() const answerElementId = `answer-${answer.id}` - const betsByUserId = groupBy(bets, (bet) => bet.userId) - const commentsByUserId = groupBy(comments, (comment) => comment.userId) - const commentsList = comments.filter( - (comment) => comment.answerOutcome === answer.number.toString() - ) const betsByCurrentUser = (user && betsByUserId[user.id]) ?? [] const commentsByCurrentUser = (user && commentsByUserId[user.id]) ?? [] const isFreeResponseContractPage = !!commentsByCurrentUser @@ -101,10 +106,13 @@ export function FeedAnswerCommentGroup(props: { }, [answerElementId, router.asPath]) return ( - <Col className={'relative flex-1 gap-3'} key={answer.id + 'comment'}> + <Col + className={'relative flex-1 items-stretch gap-3'} + key={answer.id + 'comment'} + > <Row className={clsx( - 'flex gap-3 space-x-3 pt-4 transition-all duration-1000', + 'gap-3 space-x-3 pt-4 transition-all duration-1000', highlighted ? `-m-2 my-3 rounded bg-indigo-500/[0.2] p-2` : '' )} id={answerElementId} @@ -150,21 +158,23 @@ export function FeedAnswerCommentGroup(props: { )} </Col> </Row> - <CommentRepliesList - contract={contract} - commentsList={commentsList} - betsByUserId={betsByUserId} - smallAvatar={true} - bets={bets} - tips={tips} - scrollAndOpenReplyInput={scrollAndOpenReplyInput} - treatFirstIndexEqually={true} - /> - + <Col className="gap-3 pl-1"> + {answerComments.map((comment) => ( + <FeedComment + key={comment.id} + indent={true} + contract={contract} + comment={comment} + tips={tips[comment.id]} + betsBySameUser={betsByUserId[comment.userId] ?? []} + onReplyClick={scrollAndOpenReplyInput} + /> + ))} + </Col> {showReply && ( - <div className={'ml-6'}> + <div className={'relative ml-7'}> <span - className="absolute -ml-[1px] mt-[1.25rem] h-2 w-0.5 rotate-90 bg-gray-200" + className="absolute -left-1 -ml-[1px] mt-[1.25rem] h-2 w-0.5 rotate-90 bg-gray-200" aria-hidden="true" /> <CommentInput diff --git a/web/components/feed/feed-bets.tsx b/web/components/feed/feed-bets.tsx index e4200593..cf444061 100644 --- a/web/components/feed/feed-bets.tsx +++ b/web/components/feed/feed-bets.tsx @@ -10,19 +10,14 @@ import { formatMoney, formatPercent } from 'common/util/format' import { OutcomeLabel } from 'web/components/outcome-label' import { RelativeTimestamp } from 'web/components/relative-timestamp' import React, { useEffect } from 'react' -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' +import { UserLink } from 'web/components/user-link' -export function FeedBet(props: { - contract: Contract - bet: Bet - hideOutcome: boolean - smallAvatar: boolean -}) { - const { contract, bet, hideOutcome, smallAvatar } = props +export function FeedBet(props: { contract: Contract; bet: Bet }) { + const { contract, bet } = props const { userId, createdTime } = bet const isBeforeJune2022 = dayjs(createdTime).isBefore('2022-06-01') @@ -33,21 +28,11 @@ export function FeedBet(props: { const isSelf = user?.id === userId return ( - <Row className={'flex w-full items-center gap-2 pt-3'}> + <Row className="items-center gap-2 pt-3"> {isSelf ? ( - <Avatar - className={clsx(smallAvatar && 'ml-1')} - size={smallAvatar ? 'sm' : undefined} - avatarUrl={user.avatarUrl} - username={user.username} - /> + <Avatar avatarUrl={user.avatarUrl} username={user.username} /> ) : bettor ? ( - <Avatar - className={clsx(smallAvatar && 'ml-1')} - size={smallAvatar ? 'sm' : undefined} - avatarUrl={bettor.avatarUrl} - username={bettor.username} - /> + <Avatar avatarUrl={bettor.avatarUrl} username={bettor.username} /> ) : ( <EmptyAvatar className="mx-1" /> )} @@ -56,7 +41,6 @@ export function FeedBet(props: { contract={contract} isSelf={isSelf} bettor={bettor} - hideOutcome={hideOutcome} className="flex-1" /> </Row> diff --git a/web/components/feed/feed-comments.tsx b/web/components/feed/feed-comments.tsx index d987caf5..1aebb27b 100644 --- a/web/components/feed/feed-comments.tsx +++ b/web/components/feed/feed-comments.tsx @@ -3,14 +3,13 @@ import { ContractComment } from 'common/comment' import { User } from 'common/user' import { Contract } from 'common/contract' import React, { useEffect, useState } from 'react' -import { minBy, maxBy, groupBy, partition, sumBy, Dictionary } from 'lodash' +import { minBy, maxBy, partition, sumBy, Dictionary } from 'lodash' import { useUser } from 'web/hooks/use-user' import { formatMoney } from 'common/util/format' import { useRouter } from 'next/router' import { Row } from 'web/components/layout/row' import clsx from 'clsx' 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 { firebaseLogin } from 'web/lib/firebase/users' @@ -29,62 +28,75 @@ 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' +import { UserLink } from 'web/components/user-link' export function FeedCommentThread(props: { + user: User | null | undefined contract: Contract - comments: ContractComment[] + threadComments: ContractComment[] tips: CommentTipMap parentComment: ContractComment bets: Bet[] - smallAvatar?: boolean + betsByUserId: Dictionary<Bet[]> + commentsByUserId: Dictionary<ContractComment[]> }) { - const { contract, comments, bets, tips, smallAvatar, parentComment } = props + const { + user, + contract, + threadComments, + commentsByUserId, + bets, + betsByUserId, + tips, + parentComment, + } = props const [showReply, setShowReply] = useState(false) - const [replyToUser, setReplyToUser] = - useState<{ id: string; username: string }>() - const betsByUserId = groupBy(bets, (bet) => bet.userId) - const user = useUser() - const commentsList = comments.filter( - (comment) => - parentComment.id && comment.replyToCommentId === parentComment.id - ) - commentsList.unshift(parentComment) + const [replyTo, setReplyTo] = useState<{ id: string; username: string }>() function scrollAndOpenReplyInput(comment: ContractComment) { - setReplyToUser({ id: comment.userId, username: comment.userUsername }) + setReplyTo({ id: comment.userId, username: comment.userUsername }) setShowReply(true) } return ( - <Col className={'w-full gap-3 pr-1'}> + <Col className="relative w-full items-stretch gap-3 pb-4"> <span - className="absolute top-5 left-5 -ml-px h-[calc(100%-2rem)] w-0.5 bg-gray-200" + className="absolute top-5 left-4 -ml-px h-[calc(100%-2rem)] w-0.5 bg-gray-200" aria-hidden="true" /> - <CommentRepliesList - contract={contract} - commentsList={commentsList} - betsByUserId={betsByUserId} - tips={tips} - smallAvatar={smallAvatar} - bets={bets} - scrollAndOpenReplyInput={scrollAndOpenReplyInput} - /> + {[parentComment].concat(threadComments).map((comment, commentIdx) => ( + <FeedComment + key={comment.id} + indent={commentIdx != 0} + contract={contract} + comment={comment} + tips={tips[comment.id]} + betsBySameUser={betsByUserId[comment.userId] ?? []} + onReplyClick={scrollAndOpenReplyInput} + probAtCreatedTime={ + contract.outcomeType === 'BINARY' + ? minBy(bets, (bet) => { + return bet.createdTime < comment.createdTime + ? comment.createdTime - bet.createdTime + : comment.createdTime + })?.probAfter + : undefined + } + /> + ))} {showReply && ( - <Col className={'-pb-2 ml-6'}> + <Col className="-pb-2 relative ml-6"> <span - className="absolute -ml-[1px] mt-[0.8rem] h-2 w-0.5 rotate-90 bg-gray-200" + className="absolute -left-1 -ml-[1px] mt-[0.8rem] h-2 w-0.5 rotate-90 bg-gray-200" aria-hidden="true" /> <CommentInput contract={contract} betsByCurrentUser={(user && betsByUserId[user.id]) ?? []} - commentsByCurrentUser={comments.filter( - (c) => c.userId === user?.id - )} + commentsByCurrentUser={(user && commentsByUserId[user.id]) ?? []} parentCommentId={parentComment.id} - replyToUser={replyToUser} - parentAnswerOutcome={comments[0].answerOutcome} + replyToUser={replyTo} + parentAnswerOutcome={parentComment.answerOutcome} onSubmitComment={() => setShowReply(false)} /> </Col> @@ -93,74 +105,13 @@ export function FeedCommentThread(props: { ) } -export function CommentRepliesList(props: { - contract: Contract - commentsList: ContractComment[] - betsByUserId: Dictionary<Bet[]> - tips: CommentTipMap - scrollAndOpenReplyInput: (comment: ContractComment) => void - bets: Bet[] - treatFirstIndexEqually?: boolean - smallAvatar?: boolean -}) { - const { - contract, - commentsList, - betsByUserId, - tips, - smallAvatar, - bets, - scrollAndOpenReplyInput, - treatFirstIndexEqually, - } = props - return ( - <> - {commentsList.map((comment, commentIdx) => ( - <div - key={comment.id} - id={comment.id} - className={clsx( - 'relative', - !treatFirstIndexEqually && commentIdx === 0 ? '' : 'ml-6' - )} - > - {/*draw a gray line from the comment to the left:*/} - {(treatFirstIndexEqually || commentIdx != 0) && ( - <span - className="absolute -ml-[1px] mt-[0.8rem] h-2 w-0.5 rotate-90 bg-gray-200" - aria-hidden="true" - /> - )} - <FeedComment - contract={contract} - comment={comment} - tips={tips[comment.id]} - betsBySameUser={betsByUserId[comment.userId] ?? []} - onReplyClick={scrollAndOpenReplyInput} - probAtCreatedTime={ - contract.outcomeType === 'BINARY' - ? minBy(bets, (bet) => { - return bet.createdTime < comment.createdTime - ? comment.createdTime - bet.createdTime - : comment.createdTime - })?.probAfter - : undefined - } - smallAvatar={smallAvatar} - /> - </div> - ))} - </> - ) -} - export function FeedComment(props: { contract: Contract comment: ContractComment tips: CommentTips betsBySameUser: Bet[] + indent?: boolean probAtCreatedTime?: number - smallAvatar?: boolean onReplyClick?: (comment: ContractComment) => void }) { const { @@ -168,6 +119,7 @@ export function FeedComment(props: { comment, tips, betsBySameUser, + indent, probAtCreatedTime, onReplyClick, } = props @@ -201,19 +153,23 @@ export function FeedComment(props: { return ( <Row + id={comment.id} className={clsx( - 'flex space-x-1.5 sm:space-x-3', - highlighted ? `-m-1 rounded bg-indigo-500/[0.2] p-2` : '' + 'relative', + indent ? 'ml-6' : '', + highlighted ? `-m-1.5 rounded bg-indigo-500/[0.2] p-1.5` : '' )} > - <Avatar - className={'ml-1'} - size={'sm'} - username={userUsername} - avatarUrl={userAvatarUrl} - /> - <div className="min-w-0 flex-1"> - <div className="mt-0.5 pl-0.5 text-sm text-gray-500"> + {/*draw a gray line from the comment to the left:*/} + {indent ? ( + <span + className="absolute -left-1 -ml-[1px] mt-[0.8rem] h-2 w-0.5 rotate-90 bg-gray-200" + aria-hidden="true" + /> + ) : null} + <Avatar size="sm" username={userUsername} avatarUrl={userAvatarUrl} /> + <div className="ml-1.5 min-w-0 flex-1 pl-0.5 sm:ml-3"> + <div className="mt-0.5 text-sm text-gray-500"> <UserLink className="text-gray-500" username={userUsername} @@ -231,21 +187,19 @@ export function FeedComment(props: { /> </> )} - <> - {bought} {money} - {contract.outcomeType !== 'FREE_RESPONSE' && betOutcome && ( - <> - {' '} - of{' '} - <OutcomeLabel - outcome={betOutcome ? betOutcome : ''} - value={(matchedBet as any).value} - contract={contract} - truncate="short" - /> - </> - )} - </> + {bought} {money} + {contract.outcomeType !== 'FREE_RESPONSE' && betOutcome && ( + <> + {' '} + of{' '} + <OutcomeLabel + outcome={betOutcome ? betOutcome : ''} + value={(matchedBet as any).value} + contract={contract} + truncate="short" + /> + </> + )} <CopyLinkDateTimeComponent prefix={contract.creatorUsername} slug={contract.slug} @@ -253,9 +207,11 @@ export function FeedComment(props: { elementId={comment.id} /> </div> - <div className="mt-2 text-[15px] text-gray-700"> - <Content content={content || text} smallImage /> - </div> + <Content + className="mt-2 text-[15px] text-gray-700" + content={content || text} + smallImage + /> <Row className="mt-2 items-center gap-6 text-xs text-gray-500"> <Tipper comment={comment} tips={tips ?? {}} /> {onReplyClick && ( @@ -320,6 +276,7 @@ export function CommentInput(props: { contract: Contract betsByCurrentUser: Bet[] commentsByCurrentUser: ContractComment[] + className?: string replyToUser?: { id: string; username: string } // Reply to a free response answer parentAnswerOutcome?: string @@ -331,6 +288,7 @@ export function CommentInput(props: { contract, betsByCurrentUser, commentsByCurrentUser, + className, parentAnswerOutcome, parentCommentId, replyToUser, @@ -385,60 +343,51 @@ export function CommentInput(props: { if (user?.isBannedFromPosting) return <></> return ( - <> - <Row className={'mb-2 gap-1 sm:gap-2'}> - <div className={'mt-2'}> - <Avatar - avatarUrl={user?.avatarUrl} - username={user?.username} - size={'sm'} - className={'ml-1'} - /> - </div> - <div className={'min-w-0 flex-1'}> - <div className="pl-0.5 text-sm"> - <div className="mb-1 text-gray-500"> - {mostRecentCommentableBet && ( - <BetStatusText - contract={contract} - bet={mostRecentCommentableBet} - isSelf={true} - hideOutcome={ - isNumeric || contract.outcomeType === 'FREE_RESPONSE' - } - /> - )} - {!mostRecentCommentableBet && - user && - userPosition > 0 && - !isNumeric && ( - <> - {"You're"} - <CommentStatus - outcome={outcome} - contract={contract} - prob={ - contract.outcomeType === 'BINARY' - ? getProbability(contract) - : undefined - } - /> - </> - )} - </div> - <CommentInputTextArea - editor={editor} - upload={upload} - replyToUser={replyToUser} - user={user} - submitComment={submitComment} - isSubmitting={isSubmitting} - presetId={id} + <Row className={clsx(className, 'mb-2 gap-1 sm:gap-2')}> + <Avatar + avatarUrl={user?.avatarUrl} + username={user?.username} + size="sm" + className="mt-2" + /> + <div className="min-w-0 flex-1 pl-0.5 text-sm"> + <div className="mb-1 text-gray-500"> + {mostRecentCommentableBet && ( + <BetStatusText + contract={contract} + bet={mostRecentCommentableBet} + isSelf={true} + hideOutcome={ + isNumeric || contract.outcomeType === 'FREE_RESPONSE' + } /> - </div> + )} + {!mostRecentCommentableBet && user && userPosition > 0 && !isNumeric && ( + <> + {"You're"} + <CommentStatus + outcome={outcome} + contract={contract} + prob={ + contract.outcomeType === 'BINARY' + ? getProbability(contract) + : undefined + } + /> + </> + )} </div> - </Row> - </> + <CommentInputTextArea + editor={editor} + upload={upload} + replyToUser={replyToUser} + user={user} + submitComment={submitComment} + isSubmitting={isSubmitting} + presetId={id} + /> + </div> + </Row> ) } @@ -514,23 +463,21 @@ export function CommentInputTextArea(props: { return ( <> - <div> - <TextEditor editor={editor} upload={upload}> - {user && !isSubmitting && ( - <button - 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 h-[25px] min-w-[22px] rotate-90 p-0" /> - </button> - )} + <TextEditor editor={editor} upload={upload}> + {user && !isSubmitting && ( + <button + 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 h-[25px] min-w-[22px] rotate-90 p-0" /> + </button> + )} - {isSubmitting && ( - <LoadingIndicator spinnerClassName={'border-gray-500'} /> - )} - </TextEditor> - </div> + {isSubmitting && ( + <LoadingIndicator spinnerClassName={'border-gray-500'} /> + )} + </TextEditor> <Row> {!user && ( <button @@ -555,10 +502,6 @@ function getBettorsLargestPositionBeforeTime( noShares = 0, noFloorShares = 0 - const emptyReturn = { - userPosition: 0, - outcome: '', - } const previousBets = bets.filter( (prevBet) => prevBet.createdTime < createdTime && !prevBet.isAnte ) @@ -582,7 +525,7 @@ function getBettorsLargestPositionBeforeTime( } } if (bets.length === 0) { - return emptyReturn + return { userPosition: 0, outcome: '' } } const [yesBets, noBets] = partition( diff --git a/web/components/feed/feed-items.tsx b/web/components/feed/feed-items.tsx deleted file mode 100644 index 4a121120..00000000 --- a/web/components/feed/feed-items.tsx +++ /dev/null @@ -1,279 +0,0 @@ -// From https://tailwindui.com/components/application-ui/lists/feeds -import React from 'react' -import { - BanIcon, - CheckIcon, - LockClosedIcon, - XIcon, -} from '@heroicons/react/solid' -import clsx from 'clsx' - -import { OutcomeLabel } from '../outcome-label' -import { - Contract, - contractPath, - tradingAllowed, -} from 'web/lib/firebase/contracts' -import { BinaryResolutionOrChance } from '../contract/contract-card' -import { SiteLink } from '../site-link' -import { Col } from '../layout/col' -import { UserLink } from '../user-page' -import BetButton from '../bet-button' -import { Avatar } from '../avatar' -import { ActivityItem } from './activity-items' -import { useUser } from 'web/hooks/use-user' -import { trackClick } from 'web/lib/firebase/tracking' -import { DAY_MS } from 'common/util/time' -import NewContractBadge from '../new-contract-badge' -import { RelativeTimestamp } from '../relative-timestamp' -import { FeedAnswerCommentGroup } from 'web/components/feed/feed-answer-comment-group' -import { - FeedCommentThread, - CommentInput, -} from 'web/components/feed/feed-comments' -import { FeedBet } from 'web/components/feed/feed-bets' -import { CPMMBinaryContract, NumericContract } from 'common/contract' -import { FeedLiquidity } from './feed-liquidity' -import { BetSignUpPrompt } from '../sign-up-prompt' -import { User } from 'common/user' -import { PlayMoneyDisclaimer } from '../play-money-disclaimer' -import { contractMetrics } from 'common/contract-details' - -export function FeedItems(props: { - contract: Contract - items: ActivityItem[] - className?: string - betRowClassName?: string - user: User | null | undefined -}) { - const { contract, items, className, betRowClassName, user } = props - const { outcomeType } = contract - - return ( - <div className={clsx('flow-root', className)}> - <div className={clsx(tradingAllowed(contract) ? '' : '-mb-6')}> - {items.map((item, activityItemIdx) => ( - <div key={item.id} className={'relative pb-4'}> - {activityItemIdx !== items.length - 1 || - item.type === 'answergroup' ? ( - <span - className="absolute top-5 left-5 -ml-px h-[calc(100%-2rem)] w-0.5 bg-gray-200" - aria-hidden="true" - /> - ) : null} - <div className="relative flex-col items-start space-x-3"> - <FeedItem item={item} /> - </div> - </div> - ))} - </div> - - {!user ? ( - <Col className="mt-4 max-w-sm items-center xl:hidden"> - <BetSignUpPrompt /> - <PlayMoneyDisclaimer /> - </Col> - ) : ( - outcomeType === 'BINARY' && - tradingAllowed(contract) && ( - <BetButton - contract={contract as CPMMBinaryContract} - className={clsx('mb-2', betRowClassName)} - /> - ) - )} - </div> - ) -} - -export function FeedItem(props: { item: ActivityItem }) { - const { item } = props - - switch (item.type) { - case 'question': - return <FeedQuestion {...item} /> - case 'description': - return <FeedDescription {...item} /> - case 'bet': - return <FeedBet {...item} /> - case 'liquidity': - return <FeedLiquidity {...item} /> - case 'answergroup': - return <FeedAnswerCommentGroup {...item} /> - case 'close': - return <FeedClose {...item} /> - case 'resolve': - return <FeedResolve {...item} /> - case 'commentInput': - return <CommentInput {...item} /> - case 'commentThread': - return <FeedCommentThread {...item} /> - } -} - -export function FeedQuestion(props: { - contract: Contract - contractPath?: string -}) { - const { contract } = props - const { - creatorName, - creatorUsername, - question, - outcomeType, - volume, - createdTime, - isResolved, - } = contract - const { volumeLabel } = contractMetrics(contract) - const isBinary = outcomeType === 'BINARY' - const isNew = createdTime > Date.now() - DAY_MS && !isResolved - const user = useUser() - - return ( - <div className={'flex gap-2'}> - <Avatar - username={contract.creatorUsername} - avatarUrl={contract.creatorAvatarUrl} - /> - <div className="min-w-0 flex-1 py-1.5"> - <div className="mb-2 text-sm text-gray-500"> - <UserLink - className="text-gray-900" - name={creatorName} - username={creatorUsername} - />{' '} - asked - {/* Currently hidden on mobile; ideally we'd fit this in somewhere. */} - <div className="relative -top-2 float-right "> - {isNew || volume === 0 ? ( - <NewContractBadge /> - ) : ( - <span className="hidden text-gray-400 sm:inline"> - {volumeLabel} - </span> - )} - </div> - </div> - <Col className="items-start justify-between gap-2 sm:flex-row sm:gap-4"> - <SiteLink - href={ - props.contractPath ? props.contractPath : contractPath(contract) - } - onClick={() => user && trackClick(user.id, contract.id)} - className="text-lg text-indigo-700 sm:text-xl" - > - {question} - </SiteLink> - {isBinary && ( - <BinaryResolutionOrChance - className="items-center" - contract={contract} - /> - )} - </Col> - </div> - </div> - ) -} - -function FeedDescription(props: { contract: Contract }) { - const { contract } = props - const { creatorName, creatorUsername } = contract - - return ( - <> - <Avatar - username={contract.creatorUsername} - avatarUrl={contract.creatorAvatarUrl} - /> - <div className="min-w-0 flex-1 py-1.5"> - <div className="text-sm text-gray-500"> - <UserLink - className="text-gray-900" - name={creatorName} - username={creatorUsername} - />{' '} - created this market <RelativeTimestamp time={contract.createdTime} /> - </div> - </div> - </> - ) -} - -function OutcomeIcon(props: { outcome?: string }) { - const { outcome } = props - switch (outcome) { - case 'YES': - return <CheckIcon className="h-5 w-5 text-gray-500" aria-hidden="true" /> - case 'NO': - return <XIcon className="h-5 w-5 text-gray-500" aria-hidden="true" /> - case 'CANCEL': - return <BanIcon className="h-5 w-5 text-gray-500" aria-hidden="true" /> - default: - return <CheckIcon className="h-5 w-5 text-gray-500" aria-hidden="true" /> - } -} - -function FeedResolve(props: { contract: Contract }) { - const { contract } = props - const { creatorName, creatorUsername } = contract - - const resolution = contract.resolution || 'CANCEL' - - const resolutionValue = (contract as NumericContract).resolutionValue - - return ( - <> - <div> - <div className="relative px-1"> - <div className="flex h-8 w-8 items-center justify-center rounded-full bg-gray-200"> - <OutcomeIcon outcome={resolution} /> - </div> - </div> - </div> - <div className="min-w-0 flex-1 py-1.5"> - <div className="text-sm text-gray-500"> - <UserLink - className="text-gray-900" - name={creatorName} - username={creatorUsername} - />{' '} - resolved this market to{' '} - <OutcomeLabel - outcome={resolution} - value={resolutionValue} - contract={contract} - truncate="long" - />{' '} - <RelativeTimestamp time={contract.resolutionTime || 0} /> - </div> - </div> - </> - ) -} - -function FeedClose(props: { contract: Contract }) { - const { contract } = props - - return ( - <> - <div> - <div className="relative px-1"> - <div className="flex h-8 w-8 items-center justify-center rounded-full bg-gray-200"> - <LockClosedIcon - className="h-5 w-5 text-gray-500" - aria-hidden="true" - /> - </div> - </div> - </div> - <div className="min-w-0 flex-1 py-1.5"> - <div className="text-sm text-gray-500"> - Trading closed in this market{' '} - <RelativeTimestamp time={contract.closeTime || 0} /> - </div> - </div> - </> - ) -} diff --git a/web/components/feed/feed-liquidity.tsx b/web/components/feed/feed-liquidity.tsx index 0ed06046..ee2e34e5 100644 --- a/web/components/feed/feed-liquidity.tsx +++ b/web/components/feed/feed-liquidity.tsx @@ -3,18 +3,17 @@ import { User } from 'common/user' import { useUser, useUserById } from 'web/hooks/use-user' import { Row } from 'web/components/layout/row' import { Avatar, EmptyAvatar } from 'web/components/avatar' -import clsx from 'clsx' import { formatMoney } from 'common/util/format' import { RelativeTimestamp } from 'web/components/relative-timestamp' import React from 'react' -import { UserLink } from '../user-page' import { LiquidityProvision } from 'common/liquidity-provision' +import { UserLink } from 'web/components/user-link' export function FeedLiquidity(props: { + className?: string liquidity: LiquidityProvision - smallAvatar: boolean }) { - const { liquidity, smallAvatar } = props + const { liquidity } = props const { userId, createdTime } = liquidity const isBeforeJune2022 = dayjs(createdTime).isBefore('2022-06-01') @@ -26,21 +25,11 @@ export function FeedLiquidity(props: { return ( <> - <Row className={'flex w-full gap-2 pt-3'}> + <Row className="flex w-full gap-2 pt-3"> {isSelf ? ( - <Avatar - className={clsx(smallAvatar && 'ml-1')} - size={smallAvatar ? 'sm' : undefined} - avatarUrl={user.avatarUrl} - username={user.username} - /> + <Avatar avatarUrl={user.avatarUrl} username={user.username} /> ) : bettor ? ( - <Avatar - className={clsx(smallAvatar && 'ml-1')} - size={smallAvatar ? 'sm' : undefined} - avatarUrl={bettor.avatarUrl} - username={bettor.username} - /> + <Avatar avatarUrl={bettor.avatarUrl} username={bettor.username} /> ) : ( <div className="relative px-1"> <EmptyAvatar /> diff --git a/web/components/filter-select-users.tsx b/web/components/filter-select-users.tsx index a19ab6af..415a6d57 100644 --- a/web/components/filter-select-users.tsx +++ b/web/components/filter-select-users.tsx @@ -6,8 +6,8 @@ import clsx from 'clsx' import { Menu, Transition } from '@headlessui/react' import { Avatar } from 'web/components/avatar' import { Row } from 'web/components/layout/row' -import { UserLink } from 'web/components/user-page' import { searchInAny } from 'common/util/parse' +import { UserLink } from 'web/components/user-link' export function FilterSelectUsers(props: { setSelectedUsers: (users: User[]) => void diff --git a/web/components/follow-list.tsx b/web/components/follow-list.tsx index c935f73d..65b9ef4a 100644 --- a/web/components/follow-list.tsx +++ b/web/components/follow-list.tsx @@ -6,7 +6,7 @@ import { Avatar } from './avatar' import { FollowButton } from './follow-button' import { Col } from './layout/col' import { Row } from './layout/row' -import { UserLink } from './user-page' +import { UserLink } from 'web/components/user-link' export function FollowList(props: { userIds: string[] }) { const { userIds } = props diff --git a/web/components/groups/group-about-post.tsx b/web/components/groups/group-about-post.tsx new file mode 100644 index 00000000..1b42c04d --- /dev/null +++ b/web/components/groups/group-about-post.tsx @@ -0,0 +1,141 @@ +import { useAdmin } from 'web/hooks/use-admin' +import { Row } from '../layout/row' +import { Content } from '../editor' +import { TextEditor, useTextEditor } from 'web/components/editor' +import { Button } from '../button' +import { Spacer } from '../layout/spacer' +import { Group } from 'common/group' +import { deleteFieldFromGroup, updateGroup } from 'web/lib/firebase/groups' +import PencilIcon from '@heroicons/react/solid/PencilIcon' +import { DocumentRemoveIcon } from '@heroicons/react/solid' +import { createPost } from 'web/lib/firebase/api' +import { Post } from 'common/post' +import { deletePost, updatePost } from 'web/lib/firebase/posts' +import { useState } from 'react' +import { usePost } from 'web/hooks/use-post' + +export function GroupAboutPost(props: { + group: Group + isCreator: boolean + post: Post +}) { + const { group, isCreator } = props + const post = usePost(group.aboutPostId) ?? props.post + const isAdmin = useAdmin() + + if (group.aboutPostId == null && !isCreator) { + return <p className="text-center">No post has been created </p> + } + + return ( + <div className="rounded-md bg-white p-4"> + {isCreator || isAdmin ? ( + <RichEditGroupAboutPost group={group} post={post} /> + ) : ( + <Content content={post.content} /> + )} + </div> + ) +} + +function RichEditGroupAboutPost(props: { group: Group; post: Post }) { + const { group, post } = props + const [editing, setEditing] = useState(false) + const [isSubmitting, setIsSubmitting] = useState(false) + + const { editor, upload } = useTextEditor({ + defaultValue: post.content, + disabled: isSubmitting, + }) + + async function savePost() { + if (!editor) return + const newPost = { + title: group.name, + content: editor.getJSON(), + } + + if (group.aboutPostId == null) { + const result = await createPost(newPost).catch((e) => { + console.error(e) + return e + }) + await updateGroup(group, { + aboutPostId: result.post.id, + }) + } else { + await updatePost(post, { + content: newPost.content, + }) + } + } + + async function deleteGroupAboutPost() { + await deletePost(post) + await deleteFieldFromGroup(group, 'aboutPostId') + } + + return editing ? ( + <> + <TextEditor editor={editor} upload={upload} /> + <Spacer h={2} /> + <Row className="gap-2"> + <Button + onClick={async () => { + setIsSubmitting(true) + await savePost() + setEditing(false) + setIsSubmitting(false) + }} + > + Save + </Button> + <Button color="gray" onClick={() => setEditing(false)}> + Cancel + </Button> + </Row> + </> + ) : ( + <> + {group.aboutPostId == null ? ( + <div className="text-center text-gray-500"> + <p className="text-sm"> + No post has been added yet. + <Spacer h={2} /> + <Button onClick={() => setEditing(true)}>Add a post</Button> + </p> + </div> + ) : ( + <div className="relative"> + <div className="absolute top-0 right-0 z-10 space-x-2"> + <Button + color="gray" + size="xs" + onClick={() => { + setEditing(true) + editor?.commands.focus('end') + }} + > + <PencilIcon className="inline h-4 w-4" /> + Edit + </Button> + + <Button + color="gray" + size="xs" + onClick={() => { + deleteGroupAboutPost() + }} + > + <DocumentRemoveIcon className="inline h-4 w-4" /> + Delete + </Button> + </div> + + <Content content={post.content} /> + <Spacer h={2} /> + </div> + )} + </> + ) +} diff --git a/web/components/groups/group-chat.tsx b/web/components/groups/group-chat.tsx index 244a3ffe..9a60c9c7 100644 --- a/web/components/groups/group-chat.tsx +++ b/web/components/groups/group-chat.tsx @@ -11,7 +11,6 @@ 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 { CopyLinkDateTimeComponent } from 'web/components/feed/copy-link-date-time' import { CommentTipMap, CommentTips } from 'web/hooks/use-tip-txns' import { Tipper } from 'web/components/tipper' @@ -23,6 +22,7 @@ import { useUnseenNotifications } from 'web/hooks/use-notifications' import { ChevronDownIcon, UsersIcon } from '@heroicons/react/outline' import { setNotificationsAsSeen } from 'web/pages/notifications' import { usePrivateUser } from 'web/hooks/use-user' +import { UserLink } from 'web/components/user-link' export function GroupChat(props: { messages: GroupComment[] diff --git a/web/components/linkify.tsx b/web/components/linkify.tsx index b4f05165..a24ab0b6 100644 --- a/web/components/linkify.tsx +++ b/web/components/linkify.tsx @@ -1,10 +1,15 @@ +import clsx from 'clsx' import { Fragment } from 'react' import { SiteLink } from './site-link' // Return a JSX span, linkifying @username, #hashtags, and https://... // TODO: Use a markdown parser instead of rolling our own here. -export function Linkify(props: { text: string; gray?: boolean }) { - const { text, gray } = props +export function Linkify(props: { + text: string + className?: string + gray?: boolean +}) { + const { text, className, gray } = props // Replace "m1234" with "ϻ1234" // const mRegex = /(\W|^)m(\d+)/g // text = text.replace(mRegex, (_, pre, num) => `${pre}ϻ${num}`) @@ -38,7 +43,7 @@ export function Linkify(props: { text: string; gray?: boolean }) { ) }) return ( - <span className="break-anywhere"> + <span className={clsx(className, 'break-anywhere')}> {text.split(regex).map((part, i) => ( <Fragment key={i}> {part} diff --git a/web/components/online-user-list.tsx b/web/components/online-user-list.tsx index d7f52d56..e5e006ac 100644 --- a/web/components/online-user-list.tsx +++ b/web/components/online-user-list.tsx @@ -2,13 +2,13 @@ import clsx from 'clsx' import { Avatar } from './avatar' import { Col } from './layout/col' import { Row } from './layout/row' -import { UserLink } from './user-page' import { User } from 'common/user' import { UserCircleIcon } from '@heroicons/react/solid' import { useUsers } from 'web/hooks/use-users' import { partition } from 'lodash' import { useWindowSize } from 'web/hooks/use-window-size' import { useState } from 'react' +import { UserLink } from 'web/components/user-link' const isOnline = (user?: User) => user && user.lastPingTime && user.lastPingTime > Date.now() - 5 * 60 * 1000 diff --git a/web/components/profile/user-likes-button.tsx b/web/components/profile/user-likes-button.tsx new file mode 100644 index 00000000..3d4fa9ac --- /dev/null +++ b/web/components/profile/user-likes-button.tsx @@ -0,0 +1,48 @@ +import { User } from 'common/user' +import { useState } from 'react' +import { TextButton } from 'web/components/text-button' +import { Modal } from 'web/components/layout/modal' +import { Col } from 'web/components/layout/col' +import { useUserLikedContracts } from 'web/hooks/use-likes' +import { SiteLink } from 'web/components/site-link' +import { Row } from 'web/components/layout/row' +import { XIcon } from '@heroicons/react/outline' +import { unLikeContract } from 'web/lib/firebase/likes' +import { contractPath } from 'web/lib/firebase/contracts' + +export function UserLikesButton(props: { user: User }) { + const { user } = props + const [isOpen, setIsOpen] = useState(false) + + const likedContracts = useUserLikedContracts(user.id) + + return ( + <> + <TextButton onClick={() => setIsOpen(true)}> + <span className="font-semibold">{likedContracts?.length ?? ''}</span>{' '} + Likes + </TextButton> + <Modal open={isOpen} setOpen={setIsOpen}> + <Col className="rounded bg-white p-6"> + <span className={'mb-4 text-xl'}>Liked Markets</span> + <Col className={'gap-4'}> + {likedContracts?.map((likedContract) => ( + <Row key={likedContract.id} className={'justify-between gap-2'}> + <SiteLink + href={contractPath(likedContract)} + className={'truncate text-indigo-700'} + > + {likedContract.question} + </SiteLink> + <XIcon + className="ml-2 h-5 w-5 shrink-0 cursor-pointer" + onClick={() => unLikeContract(user.id, likedContract.id)} + /> + </Row> + ))} + </Col> + </Col> + </Modal> + </> + ) +} diff --git a/web/components/referrals-button.tsx b/web/components/referrals-button.tsx index fed8fb6b..3cf77cfd 100644 --- a/web/components/referrals-button.tsx +++ b/web/components/referrals-button.tsx @@ -7,11 +7,11 @@ import { Modal } from './layout/modal' import { Tabs } from './layout/tabs' import { Row } from 'web/components/layout/row' import { Avatar } from 'web/components/avatar' -import { UserLink } from 'web/components/user-page' import { useReferrals } from 'web/hooks/use-referrals' import { FilterSelectUsers } from 'web/components/filter-select-users' import { getUser, updateUser } from 'web/lib/firebase/users' import { TextButton } from 'web/components/text-button' +import { UserLink } from 'web/components/user-link' export function ReferralsButton(props: { user: User; currentUser?: User }) { const { user, currentUser } = props diff --git a/web/components/user-link.tsx b/web/components/user-link.tsx new file mode 100644 index 00000000..5eeab1c4 --- /dev/null +++ b/web/components/user-link.tsx @@ -0,0 +1,102 @@ +import { linkClass, SiteLink } from 'web/components/site-link' +import clsx from 'clsx' +import { Row } from 'web/components/layout/row' +import { Modal } from 'web/components/layout/modal' +import { Col } from 'web/components/layout/col' +import { useState } from 'react' +import { Avatar } from 'web/components/avatar' +import { formatMoney } from 'common/util/format' + +function shortenName(name: string) { + const firstName = name.split(' ')[0] + const maxLength = 10 + const shortName = + firstName.length >= 3 + ? firstName.length < maxLength + ? firstName + : firstName.substring(0, maxLength - 3) + '...' + : name.length > maxLength + ? name.substring(0, maxLength) + '...' + : name + return shortName +} + +export function UserLink(props: { + name: string + username: string + showUsername?: boolean + className?: string + short?: boolean +}) { + const { name, username, showUsername, className, short } = props + const shortName = short ? shortenName(name) : name + return ( + <SiteLink + href={`/${username}`} + className={clsx('z-10 truncate', className)} + > + {shortName} + {showUsername && ` (@${username})`} + </SiteLink> + ) +} + +export type MultiUserLinkInfo = { + name: string + username: string + avatarUrl: string | undefined + amountTipped: number +} + +export function MultiUserTipLink(props: { + userInfos: MultiUserLinkInfo[] + className?: string +}) { + const { userInfos, className } = props + const [open, setOpen] = useState(false) + const maxShowCount = 2 + return ( + <> + <Row + className={clsx('mr-1 inline-flex gap-1', linkClass, className)} + onClick={(e) => { + e.stopPropagation() + setOpen(true) + }} + > + {userInfos.map((userInfo, index) => + index < maxShowCount ? ( + <span key={userInfo.username + 'shortened'} className={linkClass}> + {shortenName(userInfo.name) + + (index < maxShowCount - 1 ? ', ' : '')} + </span> + ) : ( + <span className={linkClass}> + & {userInfos.length - maxShowCount} more + </span> + ) + )} + </Row> + <Modal open={open} setOpen={setOpen} size={'sm'}> + <Col className="items-start gap-4 rounded-md bg-white p-6"> + <span className={'text-xl'}>Who tipped you</span> + {userInfos.map((userInfo) => ( + <Row + key={userInfo.username + 'list'} + className="w-full items-center gap-2" + > + <span className="text-primary min-w-[3.5rem]"> + +{formatMoney(userInfo.amountTipped)} + </span> + <Avatar + username={userInfo.username} + avatarUrl={userInfo.avatarUrl} + /> + <UserLink name={userInfo.name} username={userInfo.username} /> + </Row> + ))} + </Col> + </Modal> + </> + ) +} diff --git a/web/components/user-page.tsx b/web/components/user-page.tsx index a20fc58a..8312f16e 100644 --- a/web/components/user-page.tsx +++ b/web/components/user-page.tsx @@ -31,35 +31,7 @@ import { ENV_CONFIG } from 'common/envs/constants' import { BettingStreakModal } from 'web/components/profile/betting-streak-modal' import { REFERRAL_AMOUNT } from 'common/economy' import { LoansModal } from './profile/loans-modal' - -export function UserLink(props: { - name: string - username: string - showUsername?: boolean - className?: string - short?: boolean -}) { - const { name, username, showUsername, className, short } = props - const firstName = name.split(' ')[0] - const maxLength = 10 - const shortName = - firstName.length >= 3 - ? firstName.length < maxLength - ? firstName - : firstName.substring(0, maxLength - 3) + '...' - : name.length > maxLength - ? name.substring(0, maxLength) + '...' - : name - return ( - <SiteLink - href={`/${username}`} - className={clsx('z-10 truncate', className)} - > - {short ? shortName : name} - {showUsername && ` (@${username})`} - </SiteLink> - ) -} +import { UserLikesButton } from 'web/components/profile/user-likes-button' export function UserPage(props: { user: User }) { const { user } = props @@ -302,6 +274,7 @@ export function UserPage(props: { user: User }) { <FollowersButton user={user} /> <ReferralsButton user={user} /> <GroupsButton user={user} /> + <UserLikesButton user={user} /> </Row> ), }, diff --git a/web/hooks/use-contracts.ts b/web/hooks/use-contracts.ts index f277a209..3ec1c56c 100644 --- a/web/hooks/use-contracts.ts +++ b/web/hooks/use-contracts.ts @@ -95,11 +95,7 @@ export const useUpdatedContracts = (contracts: Contract[] | undefined) => { export const useUserBetContracts = (userId: string) => { const result = useFirestoreQueryData( ['contracts', 'bets', userId], - getUserBetContractsQuery(userId), - { subscribe: true, includeMetadataChanges: true }, - // Temporary workaround for react-query bug: - // https://github.com/invertase/react-query-firebase/issues/25 - { refetchOnMount: 'always' } + getUserBetContractsQuery(userId) ) return result.data } diff --git a/web/hooks/use-likes.ts b/web/hooks/use-likes.ts new file mode 100644 index 00000000..015d2c3c --- /dev/null +++ b/web/hooks/use-likes.ts @@ -0,0 +1,38 @@ +import { useEffect, useState } from 'react' +import { listenForLikes } from 'web/lib/firebase/users' +import { Like } from 'common/like' +import { Contract } from 'common/contract' +import { getContractFromId } from 'web/lib/firebase/contracts' +import { filterDefined } from 'common/util/array' + +export const useUserLikes = (userId: string | undefined) => { + const [contractIds, setContractIds] = useState<Like[] | undefined>() + + useEffect(() => { + if (userId) return listenForLikes(userId, setContractIds) + }, [userId]) + + return contractIds +} +export const useUserLikedContracts = (userId: string | undefined) => { + const [likes, setLikes] = useState<Like[] | undefined>() + const [contracts, setContracts] = useState<Contract[] | undefined>() + + useEffect(() => { + if (userId) + return listenForLikes(userId, (likes) => { + setLikes(likes.filter((l) => l.type === 'contract')) + }) + }, [userId]) + + useEffect(() => { + if (likes) + Promise.all( + likes.map(async (like) => { + return await getContractFromId(like.id) + }) + ).then((contracts) => setContracts(filterDefined(contracts))) + }, [likes]) + + return contracts +} diff --git a/web/hooks/use-notifications.ts b/web/hooks/use-notifications.ts index b2f1701f..60d0e43e 100644 --- a/web/hooks/use-notifications.ts +++ b/web/hooks/use-notifications.ts @@ -63,7 +63,13 @@ export function groupNotifications(notifications: Notification[]) { const notificationGroupsByDay = groupBy(notifications, (notification) => new Date(notification.createdTime).toDateString() ) - const incomeSourceTypes = ['bonus', 'tip', 'loan', 'betting_streak_bonus'] + const incomeSourceTypes = [ + 'bonus', + 'tip', + 'loan', + 'betting_streak_bonus', + 'tip_and_like', + ] Object.keys(notificationGroupsByDay).forEach((day) => { const notificationsGroupedByDay = notificationGroupsByDay[day] diff --git a/web/hooks/use-persistent-state.ts b/web/hooks/use-persistent-state.ts new file mode 100644 index 00000000..090aa264 --- /dev/null +++ b/web/hooks/use-persistent-state.ts @@ -0,0 +1,106 @@ +import { useEffect } from 'react' +import { useStateCheckEquality } from './use-state-check-equality' +import { NextRouter } from 'next/router' + +export type PersistenceOptions<T> = { key: string; store: PersistentStore<T> } + +export interface PersistentStore<T> { + get: (k: string) => T | undefined + set: (k: string, v: T | undefined) => void +} + +const withURLParam = (location: Location, k: string, v?: string) => { + const newParams = new URLSearchParams(location.search) + if (!v) { + newParams.delete(k) + } else { + newParams.set(k, v) + } + const newUrl = new URL(location.href) + newUrl.search = newParams.toString() + return newUrl +} + +export const storageStore = <T>(storage?: Storage): PersistentStore<T> => ({ + get: (k: string) => { + if (!storage) { + return undefined + } + const saved = storage.getItem(k) + if (typeof saved === 'string') { + try { + return JSON.parse(saved) as T + } catch (e) { + console.error(e) + } + } else { + return undefined + } + }, + set: (k: string, v: T | undefined) => { + if (storage) { + if (v === undefined) { + storage.removeItem(k) + } else { + storage.setItem(k, JSON.stringify(v)) + } + } + }, +}) + +export const urlParamStore = (router: NextRouter): PersistentStore<string> => ({ + get: (k: string) => { + const v = router.query[k] + return typeof v === 'string' ? v : undefined + }, + set: (k: string, v: string | undefined) => { + if (typeof window !== 'undefined') { + // see relevant discussion here https://github.com/vercel/next.js/discussions/18072 + const url = withURLParam(window.location, k, v).toString() + const updatedState = { ...window.history.state, as: url, url } + window.history.replaceState(updatedState, '', url) + } + }, +}) + +export const historyStore = <T>(prefix = '__manifold'): PersistentStore<T> => ({ + get: (k: string) => { + if (typeof window !== 'undefined') { + return window.history.state?.options?.[prefix]?.[k] as T | undefined + } else { + return undefined + } + }, + set: (k: string, v: T | undefined) => { + if (typeof window !== 'undefined') { + const state = window.history.state ?? {} + const options = state.options ?? {} + const inner = options[prefix] ?? {} + window.history.replaceState( + { + ...state, + options: { ...options, [prefix]: { ...inner, [k]: v } }, + }, + '' + ) + } + }, +}) + +export const usePersistentState = <T>( + initial: T, + persist?: PersistenceOptions<T> +) => { + const store = persist?.store + const key = persist?.key + // note that it's important in some cases to get the state correct during the + // first render, or scroll restoration won't take into account the saved state + const savedValue = key != null && store != null ? store.get(key) : undefined + const [state, setState] = useStateCheckEquality(savedValue ?? initial) + useEffect(() => { + if (key != null && store != null) { + store.set(key, state) + } + }, [key, state]) + return [state, setState] as const +} diff --git a/web/hooks/use-portfolio-history.ts b/web/hooks/use-portfolio-history.ts index d5919783..d01ca29b 100644 --- a/web/hooks/use-portfolio-history.ts +++ b/web/hooks/use-portfolio-history.ts @@ -8,11 +8,7 @@ export const usePortfolioHistory = (userId: string, period: Period) => { const result = useFirestoreQueryData( ['portfolio-history', userId, cutoff], - getPortfolioHistoryQuery(userId, cutoff), - { subscribe: true, includeMetadataChanges: true }, - // Temporary workaround for react-query bug: - // https://github.com/invertase/react-query-firebase/issues/25 - { refetchOnMount: 'always' } + getPortfolioHistoryQuery(userId, cutoff) ) return result.data } diff --git a/web/hooks/use-post.ts b/web/hooks/use-post.ts new file mode 100644 index 00000000..9daf2d22 --- /dev/null +++ b/web/hooks/use-post.ts @@ -0,0 +1,13 @@ +import { useEffect, useState } from 'react' +import { Post } from 'common/post' +import { listenForPost } from 'web/lib/firebase/posts' + +export const usePost = (postId: string | undefined) => { + const [post, setPost] = useState<Post | null | undefined>() + + useEffect(() => { + if (postId) return listenForPost(postId, setPost) + }, [postId]) + + return post +} diff --git a/web/hooks/use-preserve-scroll.ts b/web/hooks/use-preserve-scroll.ts deleted file mode 100644 index e314d11f..00000000 --- a/web/hooks/use-preserve-scroll.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { useRouter } from 'next/router' -import { useEffect, useRef } from 'react' - -// From: https://jak-ch-ll.medium.com/next-js-preserve-scroll-history-334cf699802a -export const usePreserveScroll = () => { - const router = useRouter() - - const scrollPositions = useRef<{ [url: string]: number }>({}) - const isBack = useRef(false) - - useEffect(() => { - router.beforePopState(() => { - isBack.current = true - return true - }) - - const onRouteChangeStart = () => { - const url = router.pathname - scrollPositions.current[url] = window.scrollY - } - - const onRouteChangeComplete = (url: any) => { - if (isBack.current && scrollPositions.current[url]) { - window.scroll({ - top: scrollPositions.current[url], - behavior: 'auto', - }) - } - - isBack.current = false - } - - router.events.on('routeChangeStart', onRouteChangeStart) - router.events.on('routeChangeComplete', onRouteChangeComplete) - - return () => { - router.events.off('routeChangeStart', onRouteChangeStart) - router.events.off('routeChangeComplete', onRouteChangeComplete) - } - }, [router]) -} diff --git a/web/hooks/use-sort-and-query-params.tsx b/web/hooks/use-sort-and-query-params.tsx deleted file mode 100644 index 0a2834d0..00000000 --- a/web/hooks/use-sort-and-query-params.tsx +++ /dev/null @@ -1,65 +0,0 @@ -import { useState } from 'react' -import { NextRouter, useRouter } from 'next/router' - -export type Sort = - | 'newest' - | 'oldest' - | 'most-traded' - | '24-hour-vol' - | 'close-date' - | 'resolve-date' - | 'last-updated' - | 'score' - -type UpdatedQueryParams = { [k: string]: string } -type QuerySortOpts = { useUrl: boolean } - -function withURLParams(location: Location, params: UpdatedQueryParams) { - const newParams = new URLSearchParams(location.search) - for (const [k, v] of Object.entries(params)) { - if (!v) { - newParams.delete(k) - } else { - newParams.set(k, v) - } - } - const newUrl = new URL(location.href) - newUrl.search = newParams.toString() - return newUrl -} - -function updateURL(params: UpdatedQueryParams) { - // see relevant discussion here https://github.com/vercel/next.js/discussions/18072 - const url = withURLParams(window.location, params).toString() - const updatedState = { ...window.history.state, as: url, url } - window.history.replaceState(updatedState, '', url) -} - -function getStringURLParam(router: NextRouter, k: string) { - const v = router.query[k] - return typeof v === 'string' ? v : null -} - -export function useQuery(defaultQuery: string, opts?: QuerySortOpts) { - const useUrl = opts?.useUrl ?? false - const router = useRouter() - const initialQuery = useUrl ? getStringURLParam(router, 'q') : null - const [query, setQuery] = useState(initialQuery ?? defaultQuery) - if (!useUrl) { - return [query, setQuery] as const - } else { - return [query, (q: string) => (setQuery(q), updateURL({ q }))] as const - } -} - -export function useSort(defaultSort: Sort, opts?: QuerySortOpts) { - const useUrl = opts?.useUrl ?? false - const router = useRouter() - const initialSort = useUrl ? (getStringURLParam(router, 's') as Sort) : null - const [sort, setSort] = useState(initialSort ?? defaultSort) - if (!useUrl) { - return [sort, setSort] as const - } else { - return [sort, (s: Sort) => (setSort(s), updateURL({ s }))] as const - } -} diff --git a/web/hooks/use-user-bets.ts b/web/hooks/use-user-bets.ts index 72a4e5bf..ff1b23b3 100644 --- a/web/hooks/use-user-bets.ts +++ b/web/hooks/use-user-bets.ts @@ -9,11 +9,7 @@ import { export const useUserBets = (userId: string) => { const result = useFirestoreQueryData( ['bets', userId], - getUserBetsQuery(userId), - { subscribe: true, includeMetadataChanges: true }, - // Temporary workaround for react-query bug: - // https://github.com/invertase/react-query-firebase/issues/25 - { refetchOnMount: 'always' } + getUserBetsQuery(userId) ) return result.data } diff --git a/web/lib/firebase/contracts.ts b/web/lib/firebase/contracts.ts index 2751e9bb..0fea53a0 100644 --- a/web/lib/firebase/contracts.ts +++ b/web/lib/firebase/contracts.ts @@ -13,7 +13,7 @@ import { updateDoc, where, } from 'firebase/firestore' -import { sortBy, sum, uniqBy } from 'lodash' +import { partition, sortBy, sum, uniqBy } from 'lodash' import { coll, getValues, listenForValue, listenForValues } from './utils' import { BinaryContract, Contract } from 'common/contract' @@ -303,62 +303,63 @@ export async function getClosingSoonContracts() { return sortBy(chooseRandomSubset(data, 2), (contract) => contract.closeTime) } -export const getRandTopCreatorContracts = async ( +export const getTopCreatorContracts = async ( creatorId: string, - count: number, - excluding: string[] = [] + count: number ) => { const creatorContractsQuery = query( contracts, where('isResolved', '==', false), where('creatorId', '==', creatorId), orderBy('popularityScore', 'desc'), - limit(count * 2) + limit(count) ) - const data = await getValues<Contract>(creatorContractsQuery) - const open = data - .filter((c) => c.closeTime && c.closeTime > Date.now()) - .filter((c) => !excluding.includes(c.id)) - - return chooseRandomSubset(open, count) + return await getValues<Contract>(creatorContractsQuery) } -export const getRandTopGroupContracts = async ( +export const getTopGroupContracts = async ( groupSlug: string, - count: number, - excluding: string[] = [] + count: number ) => { const creatorContractsQuery = query( contracts, where('groupSlugs', 'array-contains', groupSlug), where('isResolved', '==', false), orderBy('popularityScore', 'desc'), - limit(count * 2) + limit(count) ) - const data = await getValues<Contract>(creatorContractsQuery) - const open = data - .filter((c) => c.closeTime && c.closeTime > Date.now()) - .filter((c) => !excluding.includes(c.id)) - - return chooseRandomSubset(open, count) + return await getValues<Contract>(creatorContractsQuery) } export const getRecommendedContracts = async ( contract: Contract, + excludeBettorId: string, count: number ) => { const { creatorId, groupSlugs, id } = contract const [userContracts, groupContracts] = await Promise.all([ - getRandTopCreatorContracts(creatorId, count, [id]), + getTopCreatorContracts(creatorId, count * 2), groupSlugs && groupSlugs[0] - ? getRandTopGroupContracts(groupSlugs[0], count, [id]) + ? getTopGroupContracts(groupSlugs[0], count * 2) : [], ]) const combined = uniqBy([...userContracts, ...groupContracts], (c) => c.id) - return chooseRandomSubset(combined, count) + const open = combined + .filter((c) => c.closeTime && c.closeTime > Date.now()) + .filter((c) => c.id !== id) + + const [betOnContracts, nonBetOnContracts] = partition( + open, + (c) => c.uniqueBettorIds && c.uniqueBettorIds.includes(excludeBettorId) + ) + const chosen = chooseRandomSubset(nonBetOnContracts, count) + if (chosen.length < count) + chosen.push(...chooseRandomSubset(betOnContracts, count - chosen.length)) + + return chosen } export async function getRecentBetsAndComments(contract: Contract) { diff --git a/web/lib/firebase/groups.ts b/web/lib/firebase/groups.ts index 3f5d18af..28515a35 100644 --- a/web/lib/firebase/groups.ts +++ b/web/lib/firebase/groups.ts @@ -1,5 +1,6 @@ import { deleteDoc, + deleteField, doc, getDocs, query, @@ -36,6 +37,10 @@ export function updateGroup(group: Group, updates: Partial<Group>) { return updateDoc(doc(groups, group.id), updates) } +export function deleteFieldFromGroup(group: Group, field: string) { + return updateDoc(doc(groups, group.id), { [field]: deleteField() }) +} + export function deleteGroup(group: Group) { return deleteDoc(doc(groups, group.id)) } diff --git a/web/lib/firebase/likes.ts b/web/lib/firebase/likes.ts new file mode 100644 index 00000000..f16bedb7 --- /dev/null +++ b/web/lib/firebase/likes.ts @@ -0,0 +1,54 @@ +import { collection, deleteDoc, doc, setDoc } from 'firebase/firestore' +import { db } from 'web/lib/firebase/init' +import toast from 'react-hot-toast' +import { transact } from 'web/lib/firebase/api' +import { removeUndefinedProps } from 'common/util/object' +import { Like, LIKE_TIP_AMOUNT } from 'common/like' +import { track } from '@amplitude/analytics-browser' +import { User } from 'common/user' +import { Contract } from 'common/contract' + +function getLikesCollection(userId: string) { + return collection(db, 'users', userId, 'likes') +} + +export const unLikeContract = async (userId: string, contractId: string) => { + const ref = await doc(getLikesCollection(userId), contractId) + return await deleteDoc(ref) +} + +export const likeContract = async (user: User, contract: Contract) => { + if (user.balance < LIKE_TIP_AMOUNT) { + toast('You do not have enough M$ to like this market!') + return + } + let result: any = {} + if (LIKE_TIP_AMOUNT > 0) { + result = await transact({ + amount: LIKE_TIP_AMOUNT, + fromId: user.id, + fromType: 'USER', + toId: contract.creatorId, + toType: 'USER', + token: 'M$', + category: 'TIP', + data: { contractId: contract.id }, + description: `${user.name} liked contract ${contract.id} for M$ ${LIKE_TIP_AMOUNT} to ${contract.creatorId} `, + }) + console.log('result', result) + } + // create new like in db under users collection + const ref = doc(getLikesCollection(user.id), contract.id) + // contract slug and question are set via trigger + const like = removeUndefinedProps({ + id: ref.id, + userId: user.id, + createdTime: Date.now(), + type: 'contract', + tipTxnId: result.txn.id, + } as Like) + track('like', { + contractId: contract.id, + }) + await setDoc(ref, like) +} diff --git a/web/lib/firebase/posts.ts b/web/lib/firebase/posts.ts index 10bea499..162933af 100644 --- a/web/lib/firebase/posts.ts +++ b/web/lib/firebase/posts.ts @@ -7,7 +7,7 @@ import { where, } from 'firebase/firestore' import { Post } from 'common/post' -import { coll, getValue } from './utils' +import { coll, getValue, listenForValue } from './utils' export const posts = coll<Post>('posts') @@ -32,3 +32,10 @@ export async function getPostBySlug(slug: string) { const docs = (await getDocs(q)).docs return docs.length === 0 ? null : docs[0].data() } + +export function listenForPost( + postId: string, + setPost: (post: Post | null) => void +) { + return listenForValue(doc(posts, postId), setPost) +} diff --git a/web/lib/firebase/server-auth.ts b/web/lib/firebase/server-auth.ts index 7ce8a814..ff6592e2 100644 --- a/web/lib/firebase/server-auth.ts +++ b/web/lib/firebase/server-auth.ts @@ -170,7 +170,7 @@ type GetServerSidePropsAuthed<P> = ( creds: UserCredential ) => Promise<GetServerSidePropsResult<P>> -export const redirectIfLoggedIn = <P>( +export const redirectIfLoggedIn = <P extends { [k: string]: any }>( dest: string, fn?: GetServerSideProps<P> ) => { @@ -191,7 +191,7 @@ export const redirectIfLoggedIn = <P>( } } -export const redirectIfLoggedOut = <P>( +export const redirectIfLoggedOut = <P extends { [k: string]: any }>( dest: string, fn?: GetServerSidePropsAuthed<P> ) => { diff --git a/web/lib/firebase/users.ts b/web/lib/firebase/users.ts index c0764f0a..fc024e04 100644 --- a/web/lib/firebase/users.ts +++ b/web/lib/firebase/users.ts @@ -28,6 +28,7 @@ import utc from 'dayjs/plugin/utc' dayjs.extend(utc) import { track } from '@amplitude/analytics-browser' +import { Like } from 'common/like' export const users = coll<User>('users') export const privateUsers = coll<PrivateUser>('private-users') @@ -310,3 +311,11 @@ export function listenForReferrals( } ) } + +export function listenForLikes( + userId: string, + setLikes: (likes: Like[]) => void +) { + const likes = collection(users, userId, 'likes') + return listenForValues<Like>(likes, (docs) => setLikes(docs)) +} diff --git a/web/lib/util/local.ts b/web/lib/util/local.ts index 0778c0ac..d533e345 100644 --- a/web/lib/util/local.ts +++ b/web/lib/util/local.ts @@ -1,4 +1,7 @@ -export const safeLocalStorage = () => (isLocalStorage() ? localStorage : null) +export const safeLocalStorage = () => + isLocalStorage() ? localStorage : undefined +export const safeSessionStorage = () => + isSessionStorage() ? sessionStorage : undefined const isLocalStorage = () => { try { @@ -9,3 +12,13 @@ const isLocalStorage = () => { return false } } + +const isSessionStorage = () => { + try { + sessionStorage.getItem('test') + sessionStorage.setItem('hi', 'mom') + return true + } catch (e) { + return false + } +} diff --git a/web/next.config.js b/web/next.config.js index 5a418016..6ade8674 100644 --- a/web/next.config.js +++ b/web/next.config.js @@ -8,6 +8,7 @@ module.exports = { reactStrictMode: true, optimizeFonts: false, experimental: { + scrollRestoration: true, externalDir: true, modularizeImports: { '@heroicons/react/solid/?(((\\w*)?/?)*)': { diff --git a/web/pages/[username]/[contractSlug].tsx b/web/pages/[username]/[contractSlug].tsx index f9f45144..f7a5c5c5 100644 --- a/web/pages/[username]/[contractSlug].tsx +++ b/web/pages/[username]/[contractSlug].tsx @@ -52,10 +52,9 @@ export async function getStaticPropz(props: { const contract = (await getContractFromSlug(contractSlug)) || null const contractId = contract?.id - const [bets, comments, recommendedContracts] = await Promise.all([ + const [bets, comments] = await Promise.all([ contractId ? listAllBets(contractId) : [], contractId ? listAllComments(contractId) : [], - contract ? getRecommendedContracts(contract, 6) : [], ]) return { @@ -66,7 +65,6 @@ export async function getStaticPropz(props: { // Limit the data sent to the client. Client will still load all bets and comments directly. bets: bets.slice(0, 5000), comments: comments.slice(0, 1000), - recommendedContracts, }, revalidate: 60, // regenerate after a minute @@ -83,7 +81,6 @@ export default function ContractPage(props: { bets: Bet[] comments: ContractComment[] slug: string - recommendedContracts: Contract[] backToHome?: () => void }) { props = usePropz(props, getStaticPropz) ?? { @@ -91,7 +88,6 @@ export default function ContractPage(props: { username: '', comments: [], bets: [], - recommendedContracts: [], slug: '', } @@ -188,15 +184,17 @@ export function ContractPageContent( setShowConfetti(shouldSeeConfetti) }, [contract, user]) - const [recommendedContracts, setRecommendedMarkets] = useState( - props.recommendedContracts + const [recommendedContracts, setRecommendedContracts] = useState<Contract[]>( + [] ) useEffect(() => { - if (contract && recommendedContracts.length === 0) { - getRecommendedContracts(contract, 6).then(setRecommendedMarkets) + if (contract && user) { + getRecommendedContracts(contract, user.id, 6).then( + setRecommendedContracts + ) } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [contract.id, recommendedContracts]) + }, [contract.id, user?.id]) const { isResolved, question, outcomeType } = contract diff --git a/web/pages/_app.tsx b/web/pages/_app.tsx index bb620950..d5a38272 100644 --- a/web/pages/_app.tsx +++ b/web/pages/_app.tsx @@ -3,7 +3,6 @@ import type { AppProps } from 'next/app' import { useEffect } from 'react' import Head from 'next/head' import Script from 'next/script' -import { usePreserveScroll } from 'web/hooks/use-preserve-scroll' import { QueryClient, QueryClientProvider } from 'react-query' import { AuthProvider } from 'web/components/auth-context' import Welcome from 'web/components/onboarding/welcome' @@ -26,8 +25,6 @@ function printBuildInfo() { } function MyApp({ Component, pageProps }: AppProps) { - usePreserveScroll() - useEffect(printBuildInfo, []) return ( diff --git a/web/pages/api/v0/_types.ts b/web/pages/api/v0/_types.ts index 968b770e..3aa15901 100644 --- a/web/pages/api/v0/_types.ts +++ b/web/pages/api/v0/_types.ts @@ -14,6 +14,7 @@ export type LiteMarket = { id: string // Attributes about the creator + creatorId: string creatorUsername: string creatorName: string createdTime: number @@ -75,6 +76,7 @@ export class ValidationError { export function toLiteMarket(contract: Contract): LiteMarket { const { id, + creatorId, creatorUsername, creatorName, createdTime, @@ -108,6 +110,7 @@ export function toLiteMarket(contract: Contract): LiteMarket { return removeUndefinedProps({ id, + creatorId, creatorUsername, creatorName, createdTime, diff --git a/web/pages/challenges/[username]/[contractSlug]/[challengeSlug].tsx b/web/pages/challenges/[username]/[contractSlug]/[challengeSlug].tsx index f15c5809..6b8152d6 100644 --- a/web/pages/challenges/[username]/[contractSlug]/[challengeSlug].tsx +++ b/web/pages/challenges/[username]/[contractSlug]/[challengeSlug].tsx @@ -21,7 +21,6 @@ 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' @@ -33,6 +32,7 @@ import { useSaveReferral } from 'web/hooks/use-save-referral' import { BinaryContract } from 'common/contract' import { Title } from 'web/components/title' import { getOpenGraphProps } from 'common/contract-details' +import { UserLink } from 'web/components/user-link' export const getStaticProps = fromPropz(getStaticPropz) diff --git a/web/pages/challenges/index.tsx b/web/pages/challenges/index.tsx index ad4136f0..11d0f9ab 100644 --- a/web/pages/challenges/index.tsx +++ b/web/pages/challenges/index.tsx @@ -19,7 +19,6 @@ import { import { Challenge, CHALLENGES_ENABLED } 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' @@ -30,6 +29,7 @@ import toast from 'react-hot-toast' import { Modal } from 'web/components/layout/modal' import { QRCode } from 'web/components/qr-code' import { CreateChallengeModal } from 'web/components/challenges/create-challenge-modal' +import { UserLink } from 'web/components/user-link' dayjs.extend(customParseFormat) const columnClass = 'sm:px-5 px-2 py-3.5 max-w-[100px] truncate' diff --git a/web/pages/contract-search-firestore.tsx b/web/pages/contract-search-firestore.tsx index ec480269..4691030c 100644 --- a/web/pages/contract-search-firestore.tsx +++ b/web/pages/contract-search-firestore.tsx @@ -1,9 +1,13 @@ +import { useRouter } from 'next/router' import { Answer } from 'common/answer' import { searchInAny } from 'common/util/parse' import { sortBy } from 'lodash' import { ContractsGrid } from 'web/components/contract/contracts-grid' import { useContracts } from 'web/hooks/use-contracts' -import { Sort, useQuery, useSort } from 'web/hooks/use-sort-and-query-params' +import { + usePersistentState, + urlParamStore, +} from 'web/hooks/use-persistent-state' const MAX_CONTRACTS_RENDERED = 100 @@ -15,10 +19,12 @@ export default function ContractSearchFirestore(props: { groupSlug?: string } }) { - const contracts = useContracts() const { additionalFilter } = props - const [query, setQuery] = useQuery('', { useUrl: true }) - const [sort, setSort] = useSort('score', { useUrl: true }) + const contracts = useContracts() + const router = useRouter() + const store = urlParamStore(router) + const [query, setQuery] = usePersistentState('', { key: 'q', store }) + const [sort, setSort] = usePersistentState('score', { key: 'sort', store }) let matches = (contracts ?? []).filter((c) => searchInAny( @@ -34,8 +40,6 @@ export default function ContractSearchFirestore(props: { matches.sort((a, b) => b.createdTime - a.createdTime) } else if (sort === 'resolve-date') { matches = sortBy(matches, (contract) => -1 * (contract.resolutionTime ?? 0)) - } else if (sort === 'oldest') { - matches.sort((a, b) => a.createdTime - b.createdTime) } else if (sort === 'close-date') { matches = sortBy(matches, ({ volume24Hours }) => -1 * volume24Hours) matches = sortBy(matches, (contract) => contract.closeTime ?? Infinity) @@ -93,7 +97,7 @@ export default function ContractSearchFirestore(props: { <select className="select select-bordered" value={sort} - onChange={(e) => setSort(e.target.value as Sort)} + onChange={(e) => setSort(e.target.value)} > <option value="score">Trending</option> <option value="newest">Newest</option> diff --git a/web/pages/experimental/home.tsx b/web/pages/experimental/home.tsx index 380f4286..607c54d0 100644 --- a/web/pages/experimental/home.tsx +++ b/web/pages/experimental/home.tsx @@ -12,7 +12,7 @@ import { track } from 'web/lib/service/analytics' import { authenticateOnServer } from 'web/lib/firebase/server-auth' import { useSaveReferral } from 'web/hooks/use-save-referral' import { GetServerSideProps } from 'next' -import { Sort } from 'web/hooks/use-sort-and-query-params' +import { Sort } from 'web/components/contract-search' import { Button } from 'web/components/button' import { Spacer } from 'web/components/layout/spacer' import { useMemberGroups } from 'web/hooks/use-group' diff --git a/web/pages/group/[...slugs]/index.tsx b/web/pages/group/[...slugs]/index.tsx index 28658a16..bf29cc8b 100644 --- a/web/pages/group/[...slugs]/index.tsx +++ b/web/pages/group/[...slugs]/index.tsx @@ -17,7 +17,6 @@ import { updateGroup, } from 'web/lib/firebase/groups' import { Row } from 'web/components/layout/row' -import { UserLink } from 'web/components/user-page' import { firebaseLogin, getUser, User } from 'web/lib/firebase/users' import { Col } from 'web/components/layout/col' import { useUser } from 'web/hooks/use-user' @@ -45,6 +44,12 @@ import { Button } from 'web/components/button' import { listAllCommentsOnGroup } from 'web/lib/firebase/comments' import { GroupComment } from 'common/comment' import { REFERRAL_AMOUNT } from 'common/economy' +import { UserLink } from 'web/components/user-link' +import { GroupAboutPost } from 'web/components/groups/group-about-post' +import { getPost } from 'web/lib/firebase/posts' +import { Post } from 'common/post' +import { Spacer } from 'web/components/layout/spacer' +import { usePost } from 'web/hooks/use-post' export const getStaticProps = fromPropz(getStaticPropz) export async function getStaticPropz(props: { params: { slugs: string[] } }) { @@ -57,6 +62,8 @@ export async function getStaticPropz(props: { params: { slugs: string[] } }) { const contracts = (group && (await listContractsByGroupSlug(group.slug))) ?? [] + const aboutPost = + group && group.aboutPostId != null && (await getPost(group.aboutPostId)) const bets = await Promise.all( contracts.map((contract: Contract) => listAllBets(contract.id)) ) @@ -83,6 +90,7 @@ export async function getStaticPropz(props: { params: { slugs: string[] } }) { creatorScores, topCreators, messages, + aboutPost, }, revalidate: 60, // regenerate after a minute @@ -121,6 +129,7 @@ export default function GroupPage(props: { creatorScores: { [userId: string]: number } topCreators: User[] messages: GroupComment[] + aboutPost: Post }) { props = usePropz(props, getStaticPropz) ?? { group: null, @@ -146,6 +155,7 @@ export default function GroupPage(props: { const page = slugs?.[1] as typeof groupSubpages[number] const group = useGroup(props.group?.id) ?? props.group + const aboutPost = usePost(props.aboutPost?.id) ?? props.aboutPost const user = useUser() @@ -176,6 +186,16 @@ export default function GroupPage(props: { const aboutTab = ( <Col> + {group.aboutPostId != null || isCreator ? ( + <GroupAboutPost + group={group} + isCreator={!!isCreator} + post={aboutPost} + /> + ) : ( + <div></div> + )} + <Spacer h={3} /> <GroupOverview group={group} creator={creator} @@ -292,7 +312,6 @@ function GroupOverview(props: { error: "Couldn't update group", }) } - const postFix = user ? '?referrer=' + user.username : '' const shareUrl = `https://${ENV_CONFIG.domain}${groupPath( group.slug diff --git a/web/pages/groups.tsx b/web/pages/groups.tsx index 521742b2..aaf1374c 100644 --- a/web/pages/groups.tsx +++ b/web/pages/groups.tsx @@ -16,9 +16,9 @@ import { SiteLink } from 'web/components/site-link' import clsx from 'clsx' import { Avatar } from 'web/components/avatar' import { JoinOrLeaveGroupButton } from 'web/components/groups/groups-button' -import { UserLink } from 'web/components/user-page' import { searchInAny } from 'common/util/parse' import { SEO } from 'web/components/SEO' +import { UserLink } from 'web/components/user-link' export async function getStaticProps() { const groups = await listAllGroups().catch((_) => []) diff --git a/web/pages/home.tsx b/web/pages/home.tsx index 5b6c445c..65161398 100644 --- a/web/pages/home.tsx +++ b/web/pages/home.tsx @@ -1,14 +1,10 @@ -import React, { useEffect, useState } from 'react' import { useRouter } from 'next/router' import { PencilAltIcon } from '@heroicons/react/solid' import { Page } from 'web/components/page' import { Col } from 'web/components/layout/col' import { ContractSearch } from 'web/components/contract-search' -import { Contract } from 'common/contract' import { User } from 'common/user' -import { ContractPageContent } from './[username]/[contractSlug]' -import { getContractFromSlug } from 'web/lib/firebase/contracts' import { getUserAndPrivateUser } from 'web/lib/firebase/users' import { useTracking } from 'web/hooks/use-tracking' import { track } from 'web/lib/service/analytics' @@ -25,8 +21,6 @@ export const getServerSideProps: GetServerSideProps = async (ctx) => { const Home = (props: { auth: { user: User } | null }) => { const user = props.auth ? props.auth.user : null - const [contract, setContract] = useContractPage() - const router = useRouter() useTracking('view home') @@ -35,19 +29,12 @@ const Home = (props: { auth: { user: User } | null }) => { return ( <> - <Page className={contract ? 'sr-only' : ''}> + <Page> <Col className="mx-auto w-full p-2"> <ContractSearch user={user} - useQuerySortLocalStorage={true} - useQuerySortUrlParams={true} - onContractClick={(c) => { - // Show contract without navigating to contract page. - setContract(c) - // Update the url without switching pages in Nextjs. - history.pushState(null, '', `/${c.creatorUsername}/${c.slug}`) - }} - isWholePage + persistPrefix="home-search" + useQueryUrlParam={true} /> </Col> <button @@ -61,81 +48,8 @@ const Home = (props: { auth: { user: User } | null }) => { <PencilAltIcon className="h-7 w-7" aria-hidden="true" /> </button> </Page> - - {contract && ( - <ContractPageContent - contract={contract} - user={user} - username={contract.creatorUsername} - slug={contract.slug} - bets={[]} - comments={[]} - backToHome={() => { - history.back() - }} - recommendedContracts={[]} - /> - )} </> ) } -const useContractPage = () => { - const [contract, setContract] = useState<Contract | undefined>() - - useEffect(() => { - const updateContract = () => { - const path = location.pathname.split('/').slice(1) - if (path[0] === 'home') setContract(undefined) - else { - const [username, contractSlug] = path - if (!username || !contractSlug) setContract(undefined) - else { - // Show contract if route is to a contract: '/[username]/[contractSlug]'. - 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 () { - // eslint-disable-next-line prefer-rest-params - const args = [...(arguments as any)] as any - // Discard NextJS router state. - args[0] = null - pushState.apply(history, args) - updateContract() - } - - window.history.replaceState = function () { - // eslint-disable-next-line prefer-rest-params - const args = [...(arguments as any)] as any - // Discard NextJS router state. - args[0] = null - replaceState.apply(history, args) - updateContract() - } - - return () => { - removeEventListener('popstate', updateContract) - window.history.pushState = pushState - window.history.replaceState = replaceState - } - }, []) - - useEffect(() => { - if (contract) window.scrollTo(0, 0) - }, [contract]) - - return [contract, setContract] as const -} - export default Home diff --git a/web/pages/links.tsx b/web/pages/links.tsx index 6f57dc14..4c4a0be1 100644 --- a/web/pages/links.tsx +++ b/web/pages/links.tsx @@ -18,7 +18,6 @@ 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' import { CreateLinksButton } from 'web/components/manalinks/create-links-button' import { redirectIfLoggedOut } from 'web/lib/firebase/server-auth' @@ -27,6 +26,7 @@ import { Pagination } from 'web/components/pagination' import { Manalink } from 'common/manalink' import { SiteLink } from 'web/components/site-link' import { REFERRAL_AMOUNT } from 'common/economy' +import { UserLink } from 'web/components/user-link' const LINKS_PER_PAGE = 24 diff --git a/web/pages/notifications.tsx b/web/pages/notifications.tsx index 85cbcbae..f1995568 100644 --- a/web/pages/notifications.tsx +++ b/web/pages/notifications.tsx @@ -7,7 +7,6 @@ import { Page } from 'web/components/page' import { Title } from 'web/components/title' import { doc, updateDoc } from 'firebase/firestore' import { db } from 'web/lib/firebase/init' -import { UserLink } from 'web/components/user-page' import { MANIFOLD_AVATAR_URL, MANIFOLD_USERNAME, @@ -35,7 +34,7 @@ import { BETTING_STREAK_BONUS_AMOUNT, UNIQUE_BETTOR_BONUS_AMOUNT, } from 'common/economy' -import { groupBy, sum, uniq } from 'lodash' +import { groupBy, sum, uniqBy } from 'lodash' import { track } from '@amplitude/analytics-browser' import { Pagination } from 'web/components/pagination' import { useWindowSize } from 'web/hooks/use-window-size' @@ -45,10 +44,14 @@ import { SiteLink } from 'web/components/site-link' import { NotificationSettings } from 'web/components/NotificationSettings' import { SEO } from 'web/components/SEO' import { useUser } from 'web/hooks/use-user' +import { + MultiUserTipLink, + MultiUserLinkInfo, + UserLink, +} from 'web/components/user-link' import { LoadingIndicator } from 'web/components/loading-indicator' export const NOTIFICATIONS_PER_PAGE = 30 -const MULTIPLE_USERS_KEY = 'multipleUsers' const HIGHLIGHT_CLASS = 'bg-indigo-50' export const getServerSideProps = redirectIfLoggedOut('/', async (_, creds) => { @@ -233,13 +236,26 @@ function IncomeNotificationGroupItem(props: { let sum = 0 notificationsForSourceTitle.forEach( (notification) => - notification.sourceText && - (sum = parseInt(notification.sourceText) + sum) + (sum = parseInt(notification.sourceText ?? '0') + sum) ) - const uniqueUsers = uniq( + const uniqueUsers = uniqBy( notificationsForSourceTitle.map((notification) => { - return notification.sourceUserUsername - }) + let thisSum = 0 + notificationsForSourceTitle + .filter( + (n) => n.sourceUserUsername === notification.sourceUserUsername + ) + .forEach( + (n) => (thisSum = parseInt(n.sourceText ?? '0') + thisSum) + ) + return { + username: notification.sourceUserUsername, + name: notification.sourceUserName, + avatarUrl: notification.sourceUserAvatarUrl, + amountTipped: thisSum, + } as MultiUserLinkInfo + }), + (n) => n.username ) const newNotification = { @@ -247,7 +263,7 @@ function IncomeNotificationGroupItem(props: { sourceText: sum.toString(), sourceUserUsername: uniqueUsers.length > 1 - ? MULTIPLE_USERS_KEY + ? JSON.stringify(uniqueUsers) : notificationsForSourceTitle[0].sourceType, } newNotifications.push(newNotification) @@ -385,6 +401,9 @@ function IncomeNotificationItem(props: { else reasonText = 'for your' } else if (sourceType === 'loan' && sourceText) { reasonText = `of your invested bets returned as a` + // TODO: support just 'like' notification without a tip + } else if (sourceType === 'tip_and_like' && sourceText) { + reasonText = !simple ? `liked` : `in likes on` } const streakInDays = @@ -493,9 +512,11 @@ function IncomeNotificationItem(props: { <span className={'mr-1'}>{incomeNotificationLabel()}</span> </div> <span> - {sourceType === 'tip' && - (sourceUserUsername === MULTIPLE_USERS_KEY ? ( - <span className={'mr-1 truncate'}>Multiple users</span> + {(sourceType === 'tip' || sourceType === 'tip_and_like') && + (sourceUserUsername?.includes(',') ? ( + <MultiUserTipLink + userInfos={JSON.parse(sourceUserUsername)} + /> ) : ( <UserLink name={sourceUserName || ''} diff --git a/web/pages/post/[...slugs]/index.tsx b/web/pages/post/[...slugs]/index.tsx index 41c0d775..737e025f 100644 --- a/web/pages/post/[...slugs]/index.tsx +++ b/web/pages/post/[...slugs]/index.tsx @@ -5,7 +5,6 @@ import { Post } from 'common/post' import { Title } from 'web/components/title' import { Spacer } from 'web/components/layout/spacer' import { Content } from 'web/components/editor' -import { UserLink } from 'web/components/user-page' import { getUser, User } from 'web/lib/firebase/users' import { ShareIcon } from '@heroicons/react/solid' import clsx from 'clsx' @@ -16,6 +15,7 @@ import { Row } from 'web/components/layout/row' import { Col } from 'web/components/layout/col' import { ENV_CONFIG } from 'common/envs/constants' import Custom404 from 'web/pages/404' +import { UserLink } from 'web/components/user-link' export async function getStaticProps(props: { params: { slugs: string[] } }) { const { slugs } = props.params diff --git a/yarn.lock b/yarn.lock index e0e1eefa..07755708 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1744,14 +1744,14 @@ url-loader "^4.1.1" webpack "^5.69.1" -"@eslint/eslintrc@^1.2.3": - version "1.3.0" - resolved "https://registry.yarnpkg.com/@eslint/eslintrc/-/eslintrc-1.3.0.tgz#29f92c30bb3e771e4a2048c95fa6855392dfac4f" - integrity sha512-UWW0TMTmk2d7hLcWD1/e2g5HDM/HQ3csaLSqXCfqwh4uNDuNqlaKWXmEsL4Cs41Z0KnILNvwbHAah3C2yt06kw== +"@eslint/eslintrc@^1.3.1": + version "1.3.1" + resolved "https://registry.yarnpkg.com/@eslint/eslintrc/-/eslintrc-1.3.1.tgz#de0807bfeffc37b964a7d0400e0c348ce5a2543d" + integrity sha512-OhSY22oQQdw3zgPOOwdoj01l/Dzl1Z+xyUP33tkSN+aqyEhymJCcPHyXt+ylW8FSe0TfRC2VG+ROQOapD0aZSQ== dependencies: ajv "^6.12.4" debug "^4.3.2" - espree "^9.3.2" + espree "^9.4.0" globals "^13.15.0" ignore "^5.2.0" import-fresh "^3.2.1" @@ -2317,15 +2317,25 @@ resolved "https://registry.yarnpkg.com/@heroicons/react/-/react-1.0.5.tgz#2fe4df9d33eb6ce6d5178a0f862e97b61c01e27d" integrity sha512-UDMyLM2KavIu2vlWfMspapw9yii7aoLwzI2Hudx4fyoPwfKfxU8r3cL8dEBXOjcLG0/oOONZzbT14M1HoNtEcg== -"@humanwhocodes/config-array@^0.9.2": - version "0.9.5" - resolved "https://registry.yarnpkg.com/@humanwhocodes/config-array/-/config-array-0.9.5.tgz#2cbaf9a89460da24b5ca6531b8bbfc23e1df50c7" - integrity sha512-ObyMyWxZiCu/yTisA7uzx81s40xR2fD5Cg/2Kq7G02ajkNubJf6BopgDTmDyc3U7sXpNKM8cYOw7s7Tyr+DnCw== +"@humanwhocodes/config-array@^0.10.4": + version "0.10.4" + resolved "https://registry.yarnpkg.com/@humanwhocodes/config-array/-/config-array-0.10.4.tgz#01e7366e57d2ad104feea63e72248f22015c520c" + integrity sha512-mXAIHxZT3Vcpg83opl1wGlVZ9xydbfZO3r5YfRSH6Gpp2J/PfdBP0wbDa2sO6/qRbcalpoevVyW6A/fI6LfeMw== dependencies: "@humanwhocodes/object-schema" "^1.2.1" debug "^4.1.1" minimatch "^3.0.4" +"@humanwhocodes/gitignore-to-minimatch@^1.0.2": + version "1.0.2" + resolved "https://registry.yarnpkg.com/@humanwhocodes/gitignore-to-minimatch/-/gitignore-to-minimatch-1.0.2.tgz#316b0a63b91c10e53f242efb4ace5c3b34e8728d" + integrity sha512-rSqmMJDdLFUsyxR6FMtD00nfQKKLFb1kv+qBbOVKqErvloEIJLo5bDTJTQNTYgeyp78JsA7u/NPi5jT1GR/MuA== + +"@humanwhocodes/module-importer@^1.0.1": + version "1.0.1" + resolved "https://registry.yarnpkg.com/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz#af5b2691a22b44be847b0ca81641c5fb6ad0172c" + integrity sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA== + "@humanwhocodes/object-schema@^1.2.1": version "1.2.1" resolved "https://registry.yarnpkg.com/@humanwhocodes/object-schema/-/object-schema-1.2.1.tgz#b520529ec21d8e5945a1851dfd1c32e94e39ff45" @@ -3484,14 +3494,14 @@ dependencies: "@types/node" "*" -"@typescript-eslint/eslint-plugin@5.25.0": - version "5.25.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.25.0.tgz#e8ce050990e4d36cc200f2de71ca0d3eb5e77a31" - integrity sha512-icYrFnUzvm+LhW0QeJNKkezBu6tJs9p/53dpPLFH8zoM9w1tfaKzVurkPotEpAqQ8Vf8uaFyL5jHd0Vs6Z0ZQg== +"@typescript-eslint/eslint-plugin@5.36.0": + version "5.36.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.36.0.tgz#8f159c4cdb3084eb5d4b72619a2ded942aa109e5" + integrity sha512-X3In41twSDnYRES7hO2xna4ZC02SY05UN9sGW//eL1P5k4CKfvddsdC2hOq0O3+WU1wkCPQkiTY9mzSnXKkA0w== dependencies: - "@typescript-eslint/scope-manager" "5.25.0" - "@typescript-eslint/type-utils" "5.25.0" - "@typescript-eslint/utils" "5.25.0" + "@typescript-eslint/scope-manager" "5.36.0" + "@typescript-eslint/type-utils" "5.36.0" + "@typescript-eslint/utils" "5.36.0" debug "^4.3.4" functional-red-black-tree "^1.0.1" ignore "^5.2.0" @@ -3499,7 +3509,17 @@ semver "^7.3.7" tsutils "^3.21.0" -"@typescript-eslint/parser@5.25.0", "@typescript-eslint/parser@^5.21.0": +"@typescript-eslint/parser@5.36.0": + version "5.36.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-5.36.0.tgz#c08883073fb65acaafd268a987fd2314ce80c789" + integrity sha512-dlBZj7EGB44XML8KTng4QM0tvjI8swDh8MdpE5NX5iHWgWEfIuqSfSE+GPeCrCdj7m4tQLuevytd57jNDXJ2ZA== + dependencies: + "@typescript-eslint/scope-manager" "5.36.0" + "@typescript-eslint/types" "5.36.0" + "@typescript-eslint/typescript-estree" "5.36.0" + debug "^4.3.4" + +"@typescript-eslint/parser@^5.21.0": version "5.25.0" resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-5.25.0.tgz#fb533487147b4b9efd999a4d2da0b6c263b64f7f" integrity sha512-r3hwrOWYbNKP1nTcIw/aZoH+8bBnh/Lh1iDHoFpyG4DnCpvEdctrSl6LOo19fZbzypjQMHdajolxs6VpYoChgA== @@ -3517,12 +3537,21 @@ "@typescript-eslint/types" "5.25.0" "@typescript-eslint/visitor-keys" "5.25.0" -"@typescript-eslint/type-utils@5.25.0": - version "5.25.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-5.25.0.tgz#5750d26a5db4c4d68d511611e0ada04e56f613bc" - integrity sha512-B6nb3GK3Gv1Rsb2pqalebe/RyQoyG/WDy9yhj8EE0Ikds4Xa8RR28nHz+wlt4tMZk5bnAr0f3oC8TuDAd5CPrw== +"@typescript-eslint/scope-manager@5.36.0": + version "5.36.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-5.36.0.tgz#f4f859913add160318c0a5daccd3a030d1311530" + integrity sha512-PZUC9sz0uCzRiuzbkh6BTec7FqgwXW03isumFVkuPw/Ug/6nbAqPUZaRy4w99WCOUuJTjhn3tMjsM94NtEj64g== dependencies: - "@typescript-eslint/utils" "5.25.0" + "@typescript-eslint/types" "5.36.0" + "@typescript-eslint/visitor-keys" "5.36.0" + +"@typescript-eslint/type-utils@5.36.0": + version "5.36.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-5.36.0.tgz#5d2f94a36a298ae240ceca54b3bc230be9a99f0a" + integrity sha512-W/E3yJFqRYsjPljJ2gy0YkoqLJyViWs2DC6xHkXcWyhkIbCDdaVnl7mPLeQphVI+dXtY05EcXFzWLXhq8Mm/lQ== + dependencies: + "@typescript-eslint/typescript-estree" "5.36.0" + "@typescript-eslint/utils" "5.36.0" debug "^4.3.4" tsutils "^3.21.0" @@ -3531,6 +3560,11 @@ resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-5.25.0.tgz#dee51b1855788b24a2eceeae54e4adb89b088dd8" integrity sha512-7fWqfxr0KNHj75PFqlGX24gWjdV/FDBABXL5dyvBOWHpACGyveok8Uj4ipPX/1fGU63fBkzSIycEje4XsOxUFA== +"@typescript-eslint/types@5.36.0": + version "5.36.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-5.36.0.tgz#cde7b94d1c09a4f074f46db99e7bd929fb0a5559" + integrity sha512-3JJuLL1r3ljRpFdRPeOtgi14Vmpx+2JcR6gryeORmW3gPBY7R1jNYoq4yBN1L//ONZjMlbJ7SCIwugOStucYiQ== + "@typescript-eslint/typescript-estree@5.25.0": version "5.25.0" resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-5.25.0.tgz#a7ab40d32eb944e3fb5b4e3646e81b1bcdd63e00" @@ -3544,15 +3578,28 @@ semver "^7.3.7" tsutils "^3.21.0" -"@typescript-eslint/utils@5.25.0": - version "5.25.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-5.25.0.tgz#272751fd737733294b4ab95e16c7f2d4a75c2049" - integrity sha512-qNC9bhnz/n9Kba3yI6HQgQdBLuxDoMgdjzdhSInZh6NaDnFpTUlwNGxplUFWfY260Ya0TRPvkg9dd57qxrJI9g== +"@typescript-eslint/typescript-estree@5.36.0": + version "5.36.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-5.36.0.tgz#0acce61b4850bdb0e578f0884402726680608789" + integrity sha512-EW9wxi76delg/FS9+WV+fkPdwygYzRrzEucdqFVWXMQWPOjFy39mmNNEmxuO2jZHXzSQTXzhxiU1oH60AbIw9A== + dependencies: + "@typescript-eslint/types" "5.36.0" + "@typescript-eslint/visitor-keys" "5.36.0" + debug "^4.3.4" + globby "^11.1.0" + is-glob "^4.0.3" + semver "^7.3.7" + tsutils "^3.21.0" + +"@typescript-eslint/utils@5.36.0": + version "5.36.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-5.36.0.tgz#104c864ecc1448417606359275368bf3872bbabb" + integrity sha512-wAlNhXXYvAAUBbRmoJDywF/j2fhGLBP4gnreFvYvFbtlsmhMJ4qCKVh/Z8OP4SgGR3xbciX2nmG639JX0uw1OQ== dependencies: "@types/json-schema" "^7.0.9" - "@typescript-eslint/scope-manager" "5.25.0" - "@typescript-eslint/types" "5.25.0" - "@typescript-eslint/typescript-estree" "5.25.0" + "@typescript-eslint/scope-manager" "5.36.0" + "@typescript-eslint/types" "5.36.0" + "@typescript-eslint/typescript-estree" "5.36.0" eslint-scope "^5.1.1" eslint-utils "^3.0.0" @@ -3564,6 +3611,14 @@ "@typescript-eslint/types" "5.25.0" eslint-visitor-keys "^3.3.0" +"@typescript-eslint/visitor-keys@5.36.0": + version "5.36.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-5.36.0.tgz#565d35a5ca00d00a406a942397ead2cb190663ba" + integrity sha512-pdqSJwGKueOrpjYIex0T39xarDt1dn4p7XJ+6FqBWugNQwXlNGC5h62qayAIYZ/RPPtD+ButDWmpXT1eGtiaYg== + dependencies: + "@typescript-eslint/types" "5.36.0" + eslint-visitor-keys "^3.3.0" + "@webassemblyjs/ast@1.11.1": version "1.11.1" resolved "https://registry.yarnpkg.com/@webassemblyjs/ast/-/ast-1.11.1.tgz#2bfd767eae1a6996f432ff7e8d7fc75679c0b6a7" @@ -3749,11 +3804,16 @@ acorn@^7.0.0: resolved "https://registry.yarnpkg.com/acorn/-/acorn-7.4.1.tgz#feaed255973d2e77555b83dbc08851a6c63520fa" integrity sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A== -acorn@^8.0.4, acorn@^8.4.1, acorn@^8.5.0, acorn@^8.7.1: +acorn@^8.0.4, acorn@^8.4.1, acorn@^8.5.0: version "8.7.1" resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.7.1.tgz#0197122c843d1bf6d0a5e83220a788f278f63c30" integrity sha512-Xx54uLJQZ19lKygFXOWsscKUbsBZW0CPykPhVQdhIeIwrbPmJzqeASDInc8nKBnp/JT6igTs82qPXz069H8I/A== +acorn@^8.8.0: + version "8.8.0" + resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.8.0.tgz#88c0187620435c7f6015803f5539dae05a9dbea8" + integrity sha512-QOxyigPVrpZ2GXT+PFyZTl6TtOFc5egxHIP9IlQ+RbupQuX4RkT/Bee4/kQuC02Xkzg84JcT7oLYtDIQxp+v7w== + address@^1.0.1, address@^1.1.2: version "1.2.0" resolved "https://registry.yarnpkg.com/address/-/address-1.2.0.tgz#d352a62c92fee90f89a693eccd2a8b2139ab02d9" @@ -5910,13 +5970,15 @@ eslint-visitor-keys@^3.3.0: resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-3.3.0.tgz#f6480fa6b1f30efe2d1968aa8ac745b862469826" integrity sha512-mQ+suqKJVyeuwGYHAdjMFqjCyfl8+Ldnxuyp3ldiMBFKkvytrXUZWaiPCEav8qDHKty44bD+qV1IP4T+w+xXRA== -eslint@8.15.0: - version "8.15.0" - resolved "https://registry.yarnpkg.com/eslint/-/eslint-8.15.0.tgz#fea1d55a7062da48d82600d2e0974c55612a11e9" - integrity sha512-GG5USZ1jhCu8HJkzGgeK8/+RGnHaNYZGrGDzUtigK3BsGESW/rs2az23XqE0WVwDxy1VRvvjSSGu5nB0Bu+6SA== +eslint@8.23.0: + version "8.23.0" + resolved "https://registry.yarnpkg.com/eslint/-/eslint-8.23.0.tgz#a184918d288820179c6041bb3ddcc99ce6eea040" + integrity sha512-pBG/XOn0MsJcKcTRLr27S5HpzQo4kLr+HjLQIyK4EiCsijDl/TB+h5uEuJU6bQ8Edvwz1XWOjpaP2qgnXGpTcA== dependencies: - "@eslint/eslintrc" "^1.2.3" - "@humanwhocodes/config-array" "^0.9.2" + "@eslint/eslintrc" "^1.3.1" + "@humanwhocodes/config-array" "^0.10.4" + "@humanwhocodes/gitignore-to-minimatch" "^1.0.2" + "@humanwhocodes/module-importer" "^1.0.1" ajv "^6.10.0" chalk "^4.0.0" cross-spawn "^7.0.2" @@ -5926,14 +5988,17 @@ eslint@8.15.0: eslint-scope "^7.1.1" eslint-utils "^3.0.0" eslint-visitor-keys "^3.3.0" - espree "^9.3.2" + espree "^9.4.0" esquery "^1.4.0" esutils "^2.0.2" fast-deep-equal "^3.1.3" file-entry-cache "^6.0.1" + find-up "^5.0.0" functional-red-black-tree "^1.0.1" glob-parent "^6.0.1" - globals "^13.6.0" + globals "^13.15.0" + globby "^11.1.0" + grapheme-splitter "^1.0.4" ignore "^5.2.0" import-fresh "^3.0.0" imurmurhash "^0.1.4" @@ -5949,14 +6014,13 @@ eslint@8.15.0: strip-ansi "^6.0.1" strip-json-comments "^3.1.0" text-table "^0.2.0" - v8-compile-cache "^2.0.3" -espree@^9.3.2: - version "9.3.2" - resolved "https://registry.yarnpkg.com/espree/-/espree-9.3.2.tgz#f58f77bd334731182801ced3380a8cc859091596" - integrity sha512-D211tC7ZwouTIuY5x9XnS0E9sWNChB7IYKX/Xp5eQj3nFXhqmiUDB9q27y76oFl8jTg3pXcQx/bpxMfs3CIZbA== +espree@^9.4.0: + version "9.4.0" + resolved "https://registry.yarnpkg.com/espree/-/espree-9.4.0.tgz#cd4bc3d6e9336c433265fc0aa016fc1aaf182f8a" + integrity sha512-DQmnRpLj7f6TgN/NYb0MTzJXL+vJF9h3pHy4JhCIs3zwcgez8xmGg3sXHcEO97BrmO2OSvCwMdfdlyl+E9KjOw== dependencies: - acorn "^8.7.1" + acorn "^8.8.0" acorn-jsx "^5.3.2" eslint-visitor-keys "^3.3.0" @@ -6653,7 +6717,7 @@ globals@^11.1.0: resolved "https://registry.yarnpkg.com/globals/-/globals-11.12.0.tgz#ab8795338868a0babd8525758018c2a7eb95c42e" integrity sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA== -globals@^13.15.0, globals@^13.6.0: +globals@^13.15.0: version "13.15.0" resolved "https://registry.yarnpkg.com/globals/-/globals-13.15.0.tgz#38113218c907d2f7e98658af246cef8b77e90bac" integrity sha512-bpzcOlgDhMG070Av0Vy5Owklpv1I6+j96GhUI7Rh7IzDCKLzboflLrrfqMu8NquDbiR4EOQk7XzJwqVJxicxog== @@ -6752,6 +6816,11 @@ graceful-fs@^4.1.2, graceful-fs@^4.1.6, graceful-fs@^4.2.0, graceful-fs@^4.2.4, resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.10.tgz#147d3a006da4ca3ce14728c7aefc287c367d7a6c" integrity sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA== +grapheme-splitter@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/grapheme-splitter/-/grapheme-splitter-1.0.4.tgz#9cf3a665c6247479896834af35cf1dbb4400767e" + integrity sha512-bzh50DW9kTPM00T8y4o8vQg89Di9oLJVLW/KaOGIXJWP/iqCN6WKYkbNOF04vFLJhwcpYUh9ydh/+5vpOqV4YQ== + gray-matter@^4.0.3: version "4.0.3" resolved "https://registry.yarnpkg.com/gray-matter/-/gray-matter-4.0.3.tgz#e893c064825de73ea1f5f7d88c7a9f7274288798" @@ -9455,10 +9524,10 @@ prettier-plugin-tailwindcss@^0.1.5: resolved "https://registry.yarnpkg.com/prettier-plugin-tailwindcss/-/prettier-plugin-tailwindcss-0.1.11.tgz#6112da68d9d022b7f896d35c070464931c99c35f" integrity sha512-a28+1jvpIZQdZ/W97wOXb6VqI762MKE/TxMMuibMEHhyYsSxQA8Ek30KObd5kJI2HF1ldtSYprFayXJXi3pz8Q== -prettier@2.5.0: - version "2.5.0" - resolved "https://registry.yarnpkg.com/prettier/-/prettier-2.5.0.tgz#a6370e2d4594e093270419d9cc47f7670488f893" - integrity sha512-FM/zAKgWTxj40rH03VxzIPdXmj39SwSjwG0heUcNFwI+EMZJnY93yAiKXM3dObIKAM5TA88werc8T/EwhB45eg== +prettier@2.7.1: + version "2.7.1" + resolved "https://registry.yarnpkg.com/prettier/-/prettier-2.7.1.tgz#e235806850d057f97bb08368a4f7d899f7760c64" + integrity sha512-ujppO+MkdPqoVINuDFDRLClm7D78qbDt0/NR+wp5FqEZOoTNAjPHWj17QRhu7geIHJfcNhRk1XVQmF8Bp3ye+g== pretty-bytes@^5.3.0: version "5.6.0" @@ -11434,10 +11503,10 @@ typedarray-to-buffer@^3.1.5: dependencies: is-typedarray "^1.0.0" -typescript@4.6.4: - version "4.6.4" - resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.6.4.tgz#caa78bbc3a59e6a5c510d35703f6a09877ce45e9" - integrity sha512-9ia/jWHIEbo49HfjrLGfKbZSuWo9iTMwXO+Ca3pRsSpbsMbc7/IU8NKdCZVRRBafVPGnoJeFL76ZOAA84I9fEg== +typescript@4.8.2: + version "4.8.2" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.8.2.tgz#e3b33d5ccfb5914e4eeab6699cf208adee3fd790" + integrity sha512-C0I1UsrrDHo2fYI5oaCGbSejwX4ch+9Y5jTQELvovfmFkK3HHSZJB8MSJcWLmCUBzQBchCrZ9rMRV6GuNrvGtw== ua-parser-js@^0.7.30: version "0.7.31" @@ -11727,11 +11796,6 @@ v8-compile-cache-lib@^3.0.1: resolved "https://registry.yarnpkg.com/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz#6336e8d71965cb3d35a1bbb7868445a7c05264bf" integrity sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg== -v8-compile-cache@^2.0.3: - version "2.3.0" - resolved "https://registry.yarnpkg.com/v8-compile-cache/-/v8-compile-cache-2.3.0.tgz#2de19618c66dc247dcfb6f99338035d8245a2cee" - integrity sha512-l8lCEmLcLYZh4nbunNZvQCJc5pv7+RCwa8q/LdUx8u7lsWvPDKmpodJAJNwkAhJC//dFY48KuIEmjtd4RViDrA== - validate-npm-package-license@^3.0.1: version "3.0.4" resolved "https://registry.yarnpkg.com/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz#fc91f6b9c7ba15c857f4cb2c5defeec39d4f410a"