Merge branch 'main' into twitch-linking
This commit is contained in:
commit
24933165a2
|
@ -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
|
||||
|
|
|
@ -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
8
common/like.ts
Normal 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
|
|
@ -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'
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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))
|
||||
}
|
||||
|
|
|
@ -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'
|
||||
|
|
71
functions/src/on-create-like.ts
Normal file
71
functions/src/on-create-like.ts
Normal 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
|
||||
)
|
||||
}
|
32
functions/src/on-delete-like.ts
Normal file
32
functions/src/on-delete-like.ts
Normal 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,
|
||||
})
|
||||
}
|
|
@ -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) => {
|
||||
|
|
|
@ -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
|
||||
)
|
||||
|
|
10
package.json
10
package.json
|
@ -14,16 +14,16 @@
|
|||
"dependencies": {},
|
||||
"devDependencies": {
|
||||
"@types/node": "16.11.11",
|
||||
"@typescript-eslint/eslint-plugin": "5.25.0",
|
||||
"@typescript-eslint/parser": "5.25.0",
|
||||
"@typescript-eslint/eslint-plugin": "5.36.0",
|
||||
"@typescript-eslint/parser": "5.36.0",
|
||||
"concurrently": "6.5.1",
|
||||
"eslint": "8.15.0",
|
||||
"eslint": "8.23.0",
|
||||
"eslint-plugin-lodash": "^7.4.0",
|
||||
"eslint-plugin-unused-imports": "^2.0.0",
|
||||
"nodemon": "2.0.19",
|
||||
"prettier": "2.5.0",
|
||||
"prettier": "2.7.1",
|
||||
"ts-node": "10.9.1",
|
||||
"typescript": "4.6.4"
|
||||
"typescript": "4.8.2"
|
||||
},
|
||||
"resolutions": {
|
||||
"@types/react": "17.0.43"
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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')
|
||||
)
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 = {
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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)}!
|
||||
|
|
|
@ -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>
|
||||
) : (
|
||||
|
|
|
@ -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"
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
56
web/components/contract/like-market-button.tsx
Normal file
56
web/components/contract/like-market-button.tsx
Normal 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>
|
||||
)
|
||||
}
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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} />
|
||||
)
|
||||
|
|
|
@ -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()
|
||||
}
|
|
@ -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>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
|
@ -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 />
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
141
web/components/groups/group-about-post.tsx
Normal file
141
web/components/groups/group-about-post.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
|
@ -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[]
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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
|
||||
|
|
48
web/components/profile/user-likes-button.tsx
Normal file
48
web/components/profile/user-likes-button.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
|
@ -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
|
||||
|
|
102
web/components/user-link.tsx
Normal file
102
web/components/user-link.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
|
@ -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>
|
||||
),
|
||||
},
|
||||
|
|
|
@ -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
38
web/hooks/use-likes.ts
Normal 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
|
||||
}
|
|
@ -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]
|
||||
|
|
106
web/hooks/use-persistent-state.ts
Normal file
106
web/hooks/use-persistent-state.ts
Normal 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
|
||||
}
|
|
@ -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
13
web/hooks/use-post.ts
Normal 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
|
||||
}
|
|
@ -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])
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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
54
web/lib/firebase/likes.ts
Normal 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)
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
) => {
|
||||
|
|
|
@ -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))
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -8,6 +8,7 @@ module.exports = {
|
|||
reactStrictMode: true,
|
||||
optimizeFonts: false,
|
||||
experimental: {
|
||||
scrollRestoration: true,
|
||||
externalDir: true,
|
||||
modularizeImports: {
|
||||
'@heroicons/react/solid/?(((\\w*)?/?)*)': {
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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 (
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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((_) => [])
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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 || ''}
|
||||
|
|
|
@ -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
180
yarn.lock
|
@ -1744,14 +1744,14 @@
|
|||
url-loader "^4.1.1"
|
||||
webpack "^5.69.1"
|
||||
|
||||
"@eslint/eslintrc@^1.2.3":
|
||||
version "1.3.0"
|
||||
resolved "https://registry.yarnpkg.com/@eslint/eslintrc/-/eslintrc-1.3.0.tgz#29f92c30bb3e771e4a2048c95fa6855392dfac4f"
|
||||
integrity sha512-UWW0TMTmk2d7hLcWD1/e2g5HDM/HQ3csaLSqXCfqwh4uNDuNqlaKWXmEsL4Cs41Z0KnILNvwbHAah3C2yt06kw==
|
||||
"@eslint/eslintrc@^1.3.1":
|
||||
version "1.3.1"
|
||||
resolved "https://registry.yarnpkg.com/@eslint/eslintrc/-/eslintrc-1.3.1.tgz#de0807bfeffc37b964a7d0400e0c348ce5a2543d"
|
||||
integrity sha512-OhSY22oQQdw3zgPOOwdoj01l/Dzl1Z+xyUP33tkSN+aqyEhymJCcPHyXt+ylW8FSe0TfRC2VG+ROQOapD0aZSQ==
|
||||
dependencies:
|
||||
ajv "^6.12.4"
|
||||
debug "^4.3.2"
|
||||
espree "^9.3.2"
|
||||
espree "^9.4.0"
|
||||
globals "^13.15.0"
|
||||
ignore "^5.2.0"
|
||||
import-fresh "^3.2.1"
|
||||
|
@ -2317,15 +2317,25 @@
|
|||
resolved "https://registry.yarnpkg.com/@heroicons/react/-/react-1.0.5.tgz#2fe4df9d33eb6ce6d5178a0f862e97b61c01e27d"
|
||||
integrity sha512-UDMyLM2KavIu2vlWfMspapw9yii7aoLwzI2Hudx4fyoPwfKfxU8r3cL8dEBXOjcLG0/oOONZzbT14M1HoNtEcg==
|
||||
|
||||
"@humanwhocodes/config-array@^0.9.2":
|
||||
version "0.9.5"
|
||||
resolved "https://registry.yarnpkg.com/@humanwhocodes/config-array/-/config-array-0.9.5.tgz#2cbaf9a89460da24b5ca6531b8bbfc23e1df50c7"
|
||||
integrity sha512-ObyMyWxZiCu/yTisA7uzx81s40xR2fD5Cg/2Kq7G02ajkNubJf6BopgDTmDyc3U7sXpNKM8cYOw7s7Tyr+DnCw==
|
||||
"@humanwhocodes/config-array@^0.10.4":
|
||||
version "0.10.4"
|
||||
resolved "https://registry.yarnpkg.com/@humanwhocodes/config-array/-/config-array-0.10.4.tgz#01e7366e57d2ad104feea63e72248f22015c520c"
|
||||
integrity sha512-mXAIHxZT3Vcpg83opl1wGlVZ9xydbfZO3r5YfRSH6Gpp2J/PfdBP0wbDa2sO6/qRbcalpoevVyW6A/fI6LfeMw==
|
||||
dependencies:
|
||||
"@humanwhocodes/object-schema" "^1.2.1"
|
||||
debug "^4.1.1"
|
||||
minimatch "^3.0.4"
|
||||
|
||||
"@humanwhocodes/gitignore-to-minimatch@^1.0.2":
|
||||
version "1.0.2"
|
||||
resolved "https://registry.yarnpkg.com/@humanwhocodes/gitignore-to-minimatch/-/gitignore-to-minimatch-1.0.2.tgz#316b0a63b91c10e53f242efb4ace5c3b34e8728d"
|
||||
integrity sha512-rSqmMJDdLFUsyxR6FMtD00nfQKKLFb1kv+qBbOVKqErvloEIJLo5bDTJTQNTYgeyp78JsA7u/NPi5jT1GR/MuA==
|
||||
|
||||
"@humanwhocodes/module-importer@^1.0.1":
|
||||
version "1.0.1"
|
||||
resolved "https://registry.yarnpkg.com/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz#af5b2691a22b44be847b0ca81641c5fb6ad0172c"
|
||||
integrity sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==
|
||||
|
||||
"@humanwhocodes/object-schema@^1.2.1":
|
||||
version "1.2.1"
|
||||
resolved "https://registry.yarnpkg.com/@humanwhocodes/object-schema/-/object-schema-1.2.1.tgz#b520529ec21d8e5945a1851dfd1c32e94e39ff45"
|
||||
|
@ -3484,14 +3494,14 @@
|
|||
dependencies:
|
||||
"@types/node" "*"
|
||||
|
||||
"@typescript-eslint/eslint-plugin@5.25.0":
|
||||
version "5.25.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.25.0.tgz#e8ce050990e4d36cc200f2de71ca0d3eb5e77a31"
|
||||
integrity sha512-icYrFnUzvm+LhW0QeJNKkezBu6tJs9p/53dpPLFH8zoM9w1tfaKzVurkPotEpAqQ8Vf8uaFyL5jHd0Vs6Z0ZQg==
|
||||
"@typescript-eslint/eslint-plugin@5.36.0":
|
||||
version "5.36.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.36.0.tgz#8f159c4cdb3084eb5d4b72619a2ded942aa109e5"
|
||||
integrity sha512-X3In41twSDnYRES7hO2xna4ZC02SY05UN9sGW//eL1P5k4CKfvddsdC2hOq0O3+WU1wkCPQkiTY9mzSnXKkA0w==
|
||||
dependencies:
|
||||
"@typescript-eslint/scope-manager" "5.25.0"
|
||||
"@typescript-eslint/type-utils" "5.25.0"
|
||||
"@typescript-eslint/utils" "5.25.0"
|
||||
"@typescript-eslint/scope-manager" "5.36.0"
|
||||
"@typescript-eslint/type-utils" "5.36.0"
|
||||
"@typescript-eslint/utils" "5.36.0"
|
||||
debug "^4.3.4"
|
||||
functional-red-black-tree "^1.0.1"
|
||||
ignore "^5.2.0"
|
||||
|
@ -3499,7 +3509,17 @@
|
|||
semver "^7.3.7"
|
||||
tsutils "^3.21.0"
|
||||
|
||||
"@typescript-eslint/parser@5.25.0", "@typescript-eslint/parser@^5.21.0":
|
||||
"@typescript-eslint/parser@5.36.0":
|
||||
version "5.36.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-5.36.0.tgz#c08883073fb65acaafd268a987fd2314ce80c789"
|
||||
integrity sha512-dlBZj7EGB44XML8KTng4QM0tvjI8swDh8MdpE5NX5iHWgWEfIuqSfSE+GPeCrCdj7m4tQLuevytd57jNDXJ2ZA==
|
||||
dependencies:
|
||||
"@typescript-eslint/scope-manager" "5.36.0"
|
||||
"@typescript-eslint/types" "5.36.0"
|
||||
"@typescript-eslint/typescript-estree" "5.36.0"
|
||||
debug "^4.3.4"
|
||||
|
||||
"@typescript-eslint/parser@^5.21.0":
|
||||
version "5.25.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-5.25.0.tgz#fb533487147b4b9efd999a4d2da0b6c263b64f7f"
|
||||
integrity sha512-r3hwrOWYbNKP1nTcIw/aZoH+8bBnh/Lh1iDHoFpyG4DnCpvEdctrSl6LOo19fZbzypjQMHdajolxs6VpYoChgA==
|
||||
|
@ -3517,12 +3537,21 @@
|
|||
"@typescript-eslint/types" "5.25.0"
|
||||
"@typescript-eslint/visitor-keys" "5.25.0"
|
||||
|
||||
"@typescript-eslint/type-utils@5.25.0":
|
||||
version "5.25.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-5.25.0.tgz#5750d26a5db4c4d68d511611e0ada04e56f613bc"
|
||||
integrity sha512-B6nb3GK3Gv1Rsb2pqalebe/RyQoyG/WDy9yhj8EE0Ikds4Xa8RR28nHz+wlt4tMZk5bnAr0f3oC8TuDAd5CPrw==
|
||||
"@typescript-eslint/scope-manager@5.36.0":
|
||||
version "5.36.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-5.36.0.tgz#f4f859913add160318c0a5daccd3a030d1311530"
|
||||
integrity sha512-PZUC9sz0uCzRiuzbkh6BTec7FqgwXW03isumFVkuPw/Ug/6nbAqPUZaRy4w99WCOUuJTjhn3tMjsM94NtEj64g==
|
||||
dependencies:
|
||||
"@typescript-eslint/utils" "5.25.0"
|
||||
"@typescript-eslint/types" "5.36.0"
|
||||
"@typescript-eslint/visitor-keys" "5.36.0"
|
||||
|
||||
"@typescript-eslint/type-utils@5.36.0":
|
||||
version "5.36.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-5.36.0.tgz#5d2f94a36a298ae240ceca54b3bc230be9a99f0a"
|
||||
integrity sha512-W/E3yJFqRYsjPljJ2gy0YkoqLJyViWs2DC6xHkXcWyhkIbCDdaVnl7mPLeQphVI+dXtY05EcXFzWLXhq8Mm/lQ==
|
||||
dependencies:
|
||||
"@typescript-eslint/typescript-estree" "5.36.0"
|
||||
"@typescript-eslint/utils" "5.36.0"
|
||||
debug "^4.3.4"
|
||||
tsutils "^3.21.0"
|
||||
|
||||
|
@ -3531,6 +3560,11 @@
|
|||
resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-5.25.0.tgz#dee51b1855788b24a2eceeae54e4adb89b088dd8"
|
||||
integrity sha512-7fWqfxr0KNHj75PFqlGX24gWjdV/FDBABXL5dyvBOWHpACGyveok8Uj4ipPX/1fGU63fBkzSIycEje4XsOxUFA==
|
||||
|
||||
"@typescript-eslint/types@5.36.0":
|
||||
version "5.36.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-5.36.0.tgz#cde7b94d1c09a4f074f46db99e7bd929fb0a5559"
|
||||
integrity sha512-3JJuLL1r3ljRpFdRPeOtgi14Vmpx+2JcR6gryeORmW3gPBY7R1jNYoq4yBN1L//ONZjMlbJ7SCIwugOStucYiQ==
|
||||
|
||||
"@typescript-eslint/typescript-estree@5.25.0":
|
||||
version "5.25.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-5.25.0.tgz#a7ab40d32eb944e3fb5b4e3646e81b1bcdd63e00"
|
||||
|
@ -3544,15 +3578,28 @@
|
|||
semver "^7.3.7"
|
||||
tsutils "^3.21.0"
|
||||
|
||||
"@typescript-eslint/utils@5.25.0":
|
||||
version "5.25.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-5.25.0.tgz#272751fd737733294b4ab95e16c7f2d4a75c2049"
|
||||
integrity sha512-qNC9bhnz/n9Kba3yI6HQgQdBLuxDoMgdjzdhSInZh6NaDnFpTUlwNGxplUFWfY260Ya0TRPvkg9dd57qxrJI9g==
|
||||
"@typescript-eslint/typescript-estree@5.36.0":
|
||||
version "5.36.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-5.36.0.tgz#0acce61b4850bdb0e578f0884402726680608789"
|
||||
integrity sha512-EW9wxi76delg/FS9+WV+fkPdwygYzRrzEucdqFVWXMQWPOjFy39mmNNEmxuO2jZHXzSQTXzhxiU1oH60AbIw9A==
|
||||
dependencies:
|
||||
"@typescript-eslint/types" "5.36.0"
|
||||
"@typescript-eslint/visitor-keys" "5.36.0"
|
||||
debug "^4.3.4"
|
||||
globby "^11.1.0"
|
||||
is-glob "^4.0.3"
|
||||
semver "^7.3.7"
|
||||
tsutils "^3.21.0"
|
||||
|
||||
"@typescript-eslint/utils@5.36.0":
|
||||
version "5.36.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-5.36.0.tgz#104c864ecc1448417606359275368bf3872bbabb"
|
||||
integrity sha512-wAlNhXXYvAAUBbRmoJDywF/j2fhGLBP4gnreFvYvFbtlsmhMJ4qCKVh/Z8OP4SgGR3xbciX2nmG639JX0uw1OQ==
|
||||
dependencies:
|
||||
"@types/json-schema" "^7.0.9"
|
||||
"@typescript-eslint/scope-manager" "5.25.0"
|
||||
"@typescript-eslint/types" "5.25.0"
|
||||
"@typescript-eslint/typescript-estree" "5.25.0"
|
||||
"@typescript-eslint/scope-manager" "5.36.0"
|
||||
"@typescript-eslint/types" "5.36.0"
|
||||
"@typescript-eslint/typescript-estree" "5.36.0"
|
||||
eslint-scope "^5.1.1"
|
||||
eslint-utils "^3.0.0"
|
||||
|
||||
|
@ -3564,6 +3611,14 @@
|
|||
"@typescript-eslint/types" "5.25.0"
|
||||
eslint-visitor-keys "^3.3.0"
|
||||
|
||||
"@typescript-eslint/visitor-keys@5.36.0":
|
||||
version "5.36.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-5.36.0.tgz#565d35a5ca00d00a406a942397ead2cb190663ba"
|
||||
integrity sha512-pdqSJwGKueOrpjYIex0T39xarDt1dn4p7XJ+6FqBWugNQwXlNGC5h62qayAIYZ/RPPtD+ButDWmpXT1eGtiaYg==
|
||||
dependencies:
|
||||
"@typescript-eslint/types" "5.36.0"
|
||||
eslint-visitor-keys "^3.3.0"
|
||||
|
||||
"@webassemblyjs/ast@1.11.1":
|
||||
version "1.11.1"
|
||||
resolved "https://registry.yarnpkg.com/@webassemblyjs/ast/-/ast-1.11.1.tgz#2bfd767eae1a6996f432ff7e8d7fc75679c0b6a7"
|
||||
|
@ -3749,11 +3804,16 @@ acorn@^7.0.0:
|
|||
resolved "https://registry.yarnpkg.com/acorn/-/acorn-7.4.1.tgz#feaed255973d2e77555b83dbc08851a6c63520fa"
|
||||
integrity sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==
|
||||
|
||||
acorn@^8.0.4, acorn@^8.4.1, acorn@^8.5.0, acorn@^8.7.1:
|
||||
acorn@^8.0.4, acorn@^8.4.1, acorn@^8.5.0:
|
||||
version "8.7.1"
|
||||
resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.7.1.tgz#0197122c843d1bf6d0a5e83220a788f278f63c30"
|
||||
integrity sha512-Xx54uLJQZ19lKygFXOWsscKUbsBZW0CPykPhVQdhIeIwrbPmJzqeASDInc8nKBnp/JT6igTs82qPXz069H8I/A==
|
||||
|
||||
acorn@^8.8.0:
|
||||
version "8.8.0"
|
||||
resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.8.0.tgz#88c0187620435c7f6015803f5539dae05a9dbea8"
|
||||
integrity sha512-QOxyigPVrpZ2GXT+PFyZTl6TtOFc5egxHIP9IlQ+RbupQuX4RkT/Bee4/kQuC02Xkzg84JcT7oLYtDIQxp+v7w==
|
||||
|
||||
address@^1.0.1, address@^1.1.2:
|
||||
version "1.2.0"
|
||||
resolved "https://registry.yarnpkg.com/address/-/address-1.2.0.tgz#d352a62c92fee90f89a693eccd2a8b2139ab02d9"
|
||||
|
@ -5910,13 +5970,15 @@ eslint-visitor-keys@^3.3.0:
|
|||
resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-3.3.0.tgz#f6480fa6b1f30efe2d1968aa8ac745b862469826"
|
||||
integrity sha512-mQ+suqKJVyeuwGYHAdjMFqjCyfl8+Ldnxuyp3ldiMBFKkvytrXUZWaiPCEav8qDHKty44bD+qV1IP4T+w+xXRA==
|
||||
|
||||
eslint@8.15.0:
|
||||
version "8.15.0"
|
||||
resolved "https://registry.yarnpkg.com/eslint/-/eslint-8.15.0.tgz#fea1d55a7062da48d82600d2e0974c55612a11e9"
|
||||
integrity sha512-GG5USZ1jhCu8HJkzGgeK8/+RGnHaNYZGrGDzUtigK3BsGESW/rs2az23XqE0WVwDxy1VRvvjSSGu5nB0Bu+6SA==
|
||||
eslint@8.23.0:
|
||||
version "8.23.0"
|
||||
resolved "https://registry.yarnpkg.com/eslint/-/eslint-8.23.0.tgz#a184918d288820179c6041bb3ddcc99ce6eea040"
|
||||
integrity sha512-pBG/XOn0MsJcKcTRLr27S5HpzQo4kLr+HjLQIyK4EiCsijDl/TB+h5uEuJU6bQ8Edvwz1XWOjpaP2qgnXGpTcA==
|
||||
dependencies:
|
||||
"@eslint/eslintrc" "^1.2.3"
|
||||
"@humanwhocodes/config-array" "^0.9.2"
|
||||
"@eslint/eslintrc" "^1.3.1"
|
||||
"@humanwhocodes/config-array" "^0.10.4"
|
||||
"@humanwhocodes/gitignore-to-minimatch" "^1.0.2"
|
||||
"@humanwhocodes/module-importer" "^1.0.1"
|
||||
ajv "^6.10.0"
|
||||
chalk "^4.0.0"
|
||||
cross-spawn "^7.0.2"
|
||||
|
@ -5926,14 +5988,17 @@ eslint@8.15.0:
|
|||
eslint-scope "^7.1.1"
|
||||
eslint-utils "^3.0.0"
|
||||
eslint-visitor-keys "^3.3.0"
|
||||
espree "^9.3.2"
|
||||
espree "^9.4.0"
|
||||
esquery "^1.4.0"
|
||||
esutils "^2.0.2"
|
||||
fast-deep-equal "^3.1.3"
|
||||
file-entry-cache "^6.0.1"
|
||||
find-up "^5.0.0"
|
||||
functional-red-black-tree "^1.0.1"
|
||||
glob-parent "^6.0.1"
|
||||
globals "^13.6.0"
|
||||
globals "^13.15.0"
|
||||
globby "^11.1.0"
|
||||
grapheme-splitter "^1.0.4"
|
||||
ignore "^5.2.0"
|
||||
import-fresh "^3.0.0"
|
||||
imurmurhash "^0.1.4"
|
||||
|
@ -5949,14 +6014,13 @@ eslint@8.15.0:
|
|||
strip-ansi "^6.0.1"
|
||||
strip-json-comments "^3.1.0"
|
||||
text-table "^0.2.0"
|
||||
v8-compile-cache "^2.0.3"
|
||||
|
||||
espree@^9.3.2:
|
||||
version "9.3.2"
|
||||
resolved "https://registry.yarnpkg.com/espree/-/espree-9.3.2.tgz#f58f77bd334731182801ced3380a8cc859091596"
|
||||
integrity sha512-D211tC7ZwouTIuY5x9XnS0E9sWNChB7IYKX/Xp5eQj3nFXhqmiUDB9q27y76oFl8jTg3pXcQx/bpxMfs3CIZbA==
|
||||
espree@^9.4.0:
|
||||
version "9.4.0"
|
||||
resolved "https://registry.yarnpkg.com/espree/-/espree-9.4.0.tgz#cd4bc3d6e9336c433265fc0aa016fc1aaf182f8a"
|
||||
integrity sha512-DQmnRpLj7f6TgN/NYb0MTzJXL+vJF9h3pHy4JhCIs3zwcgez8xmGg3sXHcEO97BrmO2OSvCwMdfdlyl+E9KjOw==
|
||||
dependencies:
|
||||
acorn "^8.7.1"
|
||||
acorn "^8.8.0"
|
||||
acorn-jsx "^5.3.2"
|
||||
eslint-visitor-keys "^3.3.0"
|
||||
|
||||
|
@ -6653,7 +6717,7 @@ globals@^11.1.0:
|
|||
resolved "https://registry.yarnpkg.com/globals/-/globals-11.12.0.tgz#ab8795338868a0babd8525758018c2a7eb95c42e"
|
||||
integrity sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==
|
||||
|
||||
globals@^13.15.0, globals@^13.6.0:
|
||||
globals@^13.15.0:
|
||||
version "13.15.0"
|
||||
resolved "https://registry.yarnpkg.com/globals/-/globals-13.15.0.tgz#38113218c907d2f7e98658af246cef8b77e90bac"
|
||||
integrity sha512-bpzcOlgDhMG070Av0Vy5Owklpv1I6+j96GhUI7Rh7IzDCKLzboflLrrfqMu8NquDbiR4EOQk7XzJwqVJxicxog==
|
||||
|
@ -6752,6 +6816,11 @@ graceful-fs@^4.1.2, graceful-fs@^4.1.6, graceful-fs@^4.2.0, graceful-fs@^4.2.4,
|
|||
resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.10.tgz#147d3a006da4ca3ce14728c7aefc287c367d7a6c"
|
||||
integrity sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA==
|
||||
|
||||
grapheme-splitter@^1.0.4:
|
||||
version "1.0.4"
|
||||
resolved "https://registry.yarnpkg.com/grapheme-splitter/-/grapheme-splitter-1.0.4.tgz#9cf3a665c6247479896834af35cf1dbb4400767e"
|
||||
integrity sha512-bzh50DW9kTPM00T8y4o8vQg89Di9oLJVLW/KaOGIXJWP/iqCN6WKYkbNOF04vFLJhwcpYUh9ydh/+5vpOqV4YQ==
|
||||
|
||||
gray-matter@^4.0.3:
|
||||
version "4.0.3"
|
||||
resolved "https://registry.yarnpkg.com/gray-matter/-/gray-matter-4.0.3.tgz#e893c064825de73ea1f5f7d88c7a9f7274288798"
|
||||
|
@ -9455,10 +9524,10 @@ prettier-plugin-tailwindcss@^0.1.5:
|
|||
resolved "https://registry.yarnpkg.com/prettier-plugin-tailwindcss/-/prettier-plugin-tailwindcss-0.1.11.tgz#6112da68d9d022b7f896d35c070464931c99c35f"
|
||||
integrity sha512-a28+1jvpIZQdZ/W97wOXb6VqI762MKE/TxMMuibMEHhyYsSxQA8Ek30KObd5kJI2HF1ldtSYprFayXJXi3pz8Q==
|
||||
|
||||
prettier@2.5.0:
|
||||
version "2.5.0"
|
||||
resolved "https://registry.yarnpkg.com/prettier/-/prettier-2.5.0.tgz#a6370e2d4594e093270419d9cc47f7670488f893"
|
||||
integrity sha512-FM/zAKgWTxj40rH03VxzIPdXmj39SwSjwG0heUcNFwI+EMZJnY93yAiKXM3dObIKAM5TA88werc8T/EwhB45eg==
|
||||
prettier@2.7.1:
|
||||
version "2.7.1"
|
||||
resolved "https://registry.yarnpkg.com/prettier/-/prettier-2.7.1.tgz#e235806850d057f97bb08368a4f7d899f7760c64"
|
||||
integrity sha512-ujppO+MkdPqoVINuDFDRLClm7D78qbDt0/NR+wp5FqEZOoTNAjPHWj17QRhu7geIHJfcNhRk1XVQmF8Bp3ye+g==
|
||||
|
||||
pretty-bytes@^5.3.0:
|
||||
version "5.6.0"
|
||||
|
@ -11434,10 +11503,10 @@ typedarray-to-buffer@^3.1.5:
|
|||
dependencies:
|
||||
is-typedarray "^1.0.0"
|
||||
|
||||
typescript@4.6.4:
|
||||
version "4.6.4"
|
||||
resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.6.4.tgz#caa78bbc3a59e6a5c510d35703f6a09877ce45e9"
|
||||
integrity sha512-9ia/jWHIEbo49HfjrLGfKbZSuWo9iTMwXO+Ca3pRsSpbsMbc7/IU8NKdCZVRRBafVPGnoJeFL76ZOAA84I9fEg==
|
||||
typescript@4.8.2:
|
||||
version "4.8.2"
|
||||
resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.8.2.tgz#e3b33d5ccfb5914e4eeab6699cf208adee3fd790"
|
||||
integrity sha512-C0I1UsrrDHo2fYI5oaCGbSejwX4ch+9Y5jTQELvovfmFkK3HHSZJB8MSJcWLmCUBzQBchCrZ9rMRV6GuNrvGtw==
|
||||
|
||||
ua-parser-js@^0.7.30:
|
||||
version "0.7.31"
|
||||
|
@ -11727,11 +11796,6 @@ v8-compile-cache-lib@^3.0.1:
|
|||
resolved "https://registry.yarnpkg.com/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz#6336e8d71965cb3d35a1bbb7868445a7c05264bf"
|
||||
integrity sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==
|
||||
|
||||
v8-compile-cache@^2.0.3:
|
||||
version "2.3.0"
|
||||
resolved "https://registry.yarnpkg.com/v8-compile-cache/-/v8-compile-cache-2.3.0.tgz#2de19618c66dc247dcfb6f99338035d8245a2cee"
|
||||
integrity sha512-l8lCEmLcLYZh4nbunNZvQCJc5pv7+RCwa8q/LdUx8u7lsWvPDKmpodJAJNwkAhJC//dFY48KuIEmjtd4RViDrA==
|
||||
|
||||
validate-npm-package-license@^3.0.1:
|
||||
version "3.0.4"
|
||||
resolved "https://registry.yarnpkg.com/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz#fc91f6b9c7ba15c857f4cb2c5defeec39d4f410a"
|
||||
|
|
Loading…
Reference in New Issue
Block a user