Merge branch 'main' into twitch-linking

This commit is contained in:
mantikoros 2022-08-30 10:58:57 -05:00
commit 24933165a2
74 changed files with 1630 additions and 1419 deletions

View File

@ -59,6 +59,8 @@ export type Contract<T extends AnyContractType = AnyContractType> = {
popularityScore?: number
followerCount?: number
featuredOnHomeRank?: number
likedByUserIds?: string[]
likedByUserCount?: number
} & T
export type BinaryContract = Contract & Binary

View File

@ -10,6 +10,7 @@ export type Group = {
anyoneCanJoin: boolean
contractIds: string[]
aboutPostId?: string
chatDisabled?: boolean
mostRecentChatActivityTime?: number
mostRecentContractAddedTime?: number

8
common/like.ts Normal file
View File

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

View File

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

View File

@ -1,6 +1,6 @@
import { union } from 'lodash'
export const removeUndefinedProps = <T>(obj: T): T => {
export const removeUndefinedProps = <T extends object>(obj: T): T => {
const newObj: any = {}
for (const key of Object.keys(obj)) {
@ -37,4 +37,3 @@ export const subtractObjects = <T extends { [key: string]: number }>(
return newObj as T
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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<Contract[][]>([])
const [showTime, setShowTime] = useState<ShowTime | undefined>()
const [state, setState] = usePersistentState(
{
numPages: 1,
pages: [] as Contract[][],
showTime: null as ShowTime | null,
},
!persistPrefix
? undefined
: { key: `${persistPrefix}-search`, store: historyStore() }
)
const searchParameters = useRef<SearchParameters | undefined>()
const searchParams = useRef<SearchParameters | null>(null)
const searchParamsStore = historyStore<SearchParameters>()
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 <ContractSearchFirestore additionalFilter={additionalFilter} />
@ -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: {
<ContractsGrid
contracts={renderedContracts}
loadMore={noControls ? undefined : performQuery}
showTime={showTime}
showTime={state.showTime ?? undefined}
onContractClick={onContractClick}
highlightOptions={highlightOptions}
cardHideOptions={cardHideOptions}
@ -202,8 +222,8 @@ function ContractSearchControls(props: {
additionalFilter?: AdditionalFilter
hideOrderSelector?: boolean
onSearchParametersChanged: (params: SearchParameters) => 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<filter>(defaultFilter ?? 'open')
const [pillFilter, setPillFilter] = useState<string | undefined>(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 && (
<select
className="select select-bordered"
value={filter}
value={state.filter}
onChange={(e) => selectFilter(e.target.value as filter)}
>
<option value="open">Open</option>
@ -370,10 +396,10 @@ function ContractSearchControls(props: {
{!hideOrderSelector && !query && (
<select
className="select select-bordered"
value={sort}
value={state.sort}
onChange={(e) => selectSort(e.target.value as Sort)}
>
{sortOptions.map((option) => (
{SORTS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
@ -386,14 +412,14 @@ function ContractSearchControls(props: {
<Row className="scrollbar-hide items-start gap-2 overflow-x-auto">
<PillButton
key={'all'}
selected={pillFilter === undefined}
onSelect={selectPill(undefined)}
selected={state.pillFilter === undefined}
onSelect={selectPill(null)}
>
All
</PillButton>
<PillButton
key={'personal'}
selected={pillFilter === 'personal'}
selected={state.pillFilter === 'personal'}
onSelect={selectPill('personal')}
>
{user ? 'For you' : 'Featured'}
@ -402,7 +428,7 @@ function ContractSearchControls(props: {
{user && (
<PillButton
key={'your-bets'}
selected={pillFilter === 'your-bets'}
selected={state.pillFilter === 'your-bets'}
onSelect={selectPill('your-bets')}
>
Your bets
@ -413,7 +439,7 @@ function ContractSearchControls(props: {
return (
<PillButton
key={slug}
selected={pillFilter === slug}
selected={state.pillFilter === slug}
onSelect={selectPill(slug)}
>
{name}

View File

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

View File

@ -107,7 +107,6 @@ export function ContractTopTrades(props: {
comment={commentsById[topCommentId]}
tips={tips[topCommentId]}
betsBySameUser={[betsById[topCommentId]]}
smallAvatar={false}
/>
</div>
<div className="mt-2 text-sm text-gray-500">
@ -123,12 +122,7 @@ export function ContractTopTrades(props: {
<>
<Title text="💸 Smartest money" className="!mt-0" />
<div className="relative flex items-start space-x-3 rounded-md bg-gray-50 px-2 py-4">
<FeedBet
contract={contract}
bet={betsById[topBetId]}
hideOutcome={false}
smallAvatar={false}
/>
<FeedBet contract={contract} bet={betsById[topBetId]} />
</div>
<div className="mt-2 text-sm text-gray-500">
{topBettor?.name} made {formatMoney(profitById[topBetId] || 0)}!

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

38
web/hooks/use-likes.ts Normal file
View File

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

View File

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

View File

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

View File

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

13
web/hooks/use-post.ts Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

54
web/lib/firebase/likes.ts Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -8,6 +8,7 @@ module.exports = {
reactStrictMode: true,
optimizeFonts: false,
experimental: {
scrollRestoration: true,
externalDir: true,
modularizeImports: {
'@heroicons/react/solid/?(((\\w*)?/?)*)': {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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((_) => [])

View File

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

View File

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

View File

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

View File

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

180
yarn.lock
View File

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