Merge branch 'main' into range-markets

This commit is contained in:
mantikoros 2022-05-12 16:56:52 -04:00
commit 852a4c7614
34 changed files with 883 additions and 369 deletions

1
.gitignore vendored
View File

@ -2,3 +2,4 @@
.idea/
.vercel
node_modules
yarn-error.log

25
common/categories.ts Normal file
View File

@ -0,0 +1,25 @@
export const CATEGORIES = {
politics: 'Politics',
technology: 'Technology',
sports: 'Sports',
gaming: 'Gaming',
manifold: 'Manifold',
science: 'Science',
world: 'World',
fun: 'Fun',
personal: 'Personal',
economics: 'Economics',
crypto: 'Crypto',
health: 'Health',
// entertainment: 'Entertainment',
// society: 'Society',
// friends: 'Friends / Community',
// business: 'Business',
// charity: 'Charities / Non-profits',
} as { [category: string]: string }
export const TO_CATEGORY = Object.fromEntries(
Object.entries(CATEGORIES).map(([k, v]) => [v, k])
)
export const CATEGORY_LIST = Object.keys(CATEGORIES)

View File

@ -5,6 +5,7 @@ export type Comment = {
contractId: string
betId?: string
answerOutcome?: string
replyToCommentId?: string
userId: string
text: string

9
common/feed.ts Normal file
View File

@ -0,0 +1,9 @@
import { Bet } from './bet'
import { Comment } from './comment'
import { Contract } from './contract'
export type feed = {
contract: Contract
recentBets: Bet[]
recentComments: Comment[]
}[]

View File

@ -17,6 +17,8 @@ export type User = {
totalDeposits: number
totalPnLCached: number
creatorVolumeCached: number
followedCategories?: string[]
}
export const STARTING_BALANCE = 1000

View File

@ -16,7 +16,7 @@ service cloud.firestore {
allow read;
allow update: if resource.data.id == request.auth.uid
&& request.resource.data.diff(resource.data).affectedKeys()
.hasOnly(['bio', 'bannerUrl', 'website', 'twitterHandle', 'discordHandle']);
.hasOnly(['bio', 'bannerUrl', 'website', 'twitterHandle', 'discordHandle', 'followedCategories']);
}
match /private-users/{userId} {
@ -35,7 +35,7 @@ service cloud.firestore {
allow create: if userId == request.auth.uid;
}
match /private-users/{userId}/cache/feed {
match /private-users/{userId}/cache/{docId} {
allow read: if userId == request.auth.uid || isAdmin();
}

View File

@ -19,11 +19,13 @@
},
"main": "lib/functions/src/index.js",
"dependencies": {
"@react-query-firebase/firestore": "0.4.2",
"fetch": "1.1.0",
"firebase-admin": "10.0.0",
"firebase-functions": "3.16.0",
"lodash": "4.17.21",
"mailgun-js": "0.22.0",
"react-query": "3.39.0",
"module-alias": "2.2.2",
"stripe": "8.194.0"
},

View File

@ -7,8 +7,8 @@ import { getNewMultiBetInfo } from 'common/new-bet'
import { Answer, MAX_ANSWER_LENGTH } from 'common/answer'
import { getContract, getValues } from './utils'
import { sendNewAnswerEmail } from './emails'
import { Bet } from 'common/bet'
import { hasUserHitManaLimit } from 'common/calculate'
import { Bet } from '../../common/bet'
import { hasUserHitManaLimit } from '../../common/calculate'
export const createAnswer = functions.runWith({ minInstances: 1 }).https.onCall(
async (

View File

@ -1,6 +1,7 @@
import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin'
import * as _ from 'lodash'
import { chargeUser, getUser } from './utils'
import {
Binary,

View File

@ -0,0 +1,71 @@
import * as admin from 'firebase-admin'
import { Bet } from '../../common/bet'
import { Contract } from '../../common/contract'
import { DAY_MS } from '../../common/util/time'
import { getValues } from './utils'
const firestore = admin.firestore()
export async function getFeedContracts() {
// Get contracts bet on or created in last week.
const [activeContracts, inactiveContracts] = await Promise.all([
getValues<Contract>(
firestore
.collection('contracts')
.where('isResolved', '==', false)
.where('volume7Days', '>', 0)
),
getValues<Contract>(
firestore
.collection('contracts')
.where('isResolved', '==', false)
.where('createdTime', '>', Date.now() - DAY_MS * 7)
.where('volume7Days', '==', 0)
),
])
const combined = [...activeContracts, ...inactiveContracts]
// Remove closed contracts.
return combined.filter((c) => (c.closeTime ?? Infinity) > Date.now())
}
export async function getTaggedContracts(tag: string) {
const taggedContracts = await getValues<Contract>(
firestore
.collection('contracts')
.where('isResolved', '==', false)
.where('lowercaseTags', 'array-contains', tag.toLowerCase())
)
// Remove closed contracts.
return taggedContracts.filter((c) => (c.closeTime ?? Infinity) > Date.now())
}
export async function getRecentBetsAndComments(contract: Contract) {
const contractDoc = firestore.collection('contracts').doc(contract.id)
const [recentBets, recentComments] = await Promise.all([
getValues<Bet>(
contractDoc
.collection('bets')
.where('createdTime', '>', Date.now() - DAY_MS)
.orderBy('createdTime', 'desc')
.limit(1)
),
getValues<Comment>(
contractDoc
.collection('comments')
.where('createdTime', '>', Date.now() - 3 * DAY_MS)
.orderBy('createdTime', 'desc')
.limit(3)
),
])
return {
contract,
recentBets,
recentComments,
}
}

View File

@ -9,7 +9,9 @@ import { User } from 'common/user'
import { batchedWaitAll } from 'common/util/promise'
import { Contract } from 'common/contract'
import { updateWordScores } from '../update-recommendations'
import { getFeedContracts, doUserFeedUpdate } from '../update-feed'
import { computeFeed } from '../update-feed'
import { getFeedContracts, getTaggedContracts } from '../get-feed-data'
import { CATEGORY_LIST } from '../../../common/categories'
const firestore = admin.firestore()
@ -19,8 +21,7 @@ async function updateFeed() {
const contracts = await getValues<Contract>(firestore.collection('contracts'))
const feedContracts = await getFeedContracts()
const users = await getValues<User>(
firestore.collection('users')
// .where('username', '==', 'JamesGrugett')
firestore.collection('users').where('username', '==', 'JamesGrugett')
)
await batchedWaitAll(
@ -28,7 +29,22 @@ async function updateFeed() {
console.log('Updating recs for', user.username)
await updateWordScores(user, contracts)
console.log('Updating feed for', user.username)
await doUserFeedUpdate(user, feedContracts)
await computeFeed(user, feedContracts)
})
)
console.log('Updating feed categories!')
await batchedWaitAll(
users.map((user) => async () => {
for (const category of CATEGORY_LIST) {
const contracts = await getTaggedContracts(category)
const feed = await computeFeed(user, contracts)
await firestore
.collection(`private-users/${user.id}/cache`)
.doc(`feed-${category}`)
.set({ feed })
}
})
)
}

View File

@ -10,15 +10,19 @@ import {
getProbability,
getOutcomeProbability,
getTopAnswer,
} from 'common/calculate'
import { Bet } from 'common/bet'
import { Comment } from 'common/comment'
import { User } from 'common/user'
} from '../../common/calculate'
import { User } from '../../common/user'
import {
getContractScore,
MAX_FEED_CONTRACTS,
} from 'common/recommended-contracts'
import { callCloudFunction } from './call-cloud-function'
import {
getFeedContracts,
getRecentBetsAndComments,
getTaggedContracts,
} from './get-feed-data'
import { CATEGORY_LIST } from '../../common/categories'
const firestore = admin.firestore()
@ -28,16 +32,28 @@ export const updateFeed = functions.pubsub
const users = await getValues<User>(firestore.collection('users'))
const batchSize = 100
const userBatches: User[][] = []
let userBatches: User[][] = []
for (let i = 0; i < users.length; i += batchSize) {
userBatches.push(users.slice(i, i + batchSize))
}
console.log('updating feed batch')
await Promise.all(
userBatches.map(async (users) =>
userBatches.map((users) =>
callCloudFunction('updateFeedBatch', { users })
)
)
console.log('updating category feed')
await Promise.all(
CATEGORY_LIST.map((category) =>
callCloudFunction('updateCategoryFeed', {
category,
})
)
)
})
export const updateFeedBatch = functions.https.onCall(
@ -45,40 +61,56 @@ export const updateFeedBatch = functions.https.onCall(
const { users } = data
const contracts = await getFeedContracts()
await Promise.all(users.map((user) => doUserFeedUpdate(user, contracts)))
await Promise.all(
users.map(async (user) => {
const feed = await computeFeed(user, contracts)
await getUserCacheCollection(user).doc('feed').set({ feed })
})
)
}
)
export const updateCategoryFeed = functions.https.onCall(
async (data: { category: string }) => {
const { category } = data
const users = await getValues<User>(firestore.collection('users'))
const batchSize = 100
const userBatches: User[][] = []
for (let i = 0; i < users.length; i += batchSize) {
userBatches.push(users.slice(i, i + batchSize))
}
await Promise.all(
userBatches.map(async (users) => {
await callCloudFunction('updateCategoryFeedBatch', {
users,
category,
})
})
)
}
)
export async function getFeedContracts() {
// Get contracts bet on or created in last week.
const contracts = await Promise.all([
getValues<Contract>(
firestore
.collection('contracts')
.where('isResolved', '==', false)
.where('volume7Days', '>', 0)
),
export const updateCategoryFeedBatch = functions.https.onCall(
async (data: { users: User[]; category: string }) => {
const { users, category } = data
const contracts = await getTaggedContracts(category)
getValues<Contract>(
firestore
.collection('contracts')
.where('isResolved', '==', false)
.where('createdTime', '>', Date.now() - DAY_MS * 7)
.where('volume7Days', '==', 0)
),
]).then(([activeContracts, inactiveContracts]) => {
const combined = [...activeContracts, ...inactiveContracts]
// Remove closed contracts.
return combined.filter((c) => (c.closeTime ?? Infinity) > Date.now())
await Promise.all(
users.map(async (user) => {
const feed = await computeFeed(user, contracts)
await getUserCacheCollection(user).doc(`feed-${category}`).set({ feed })
})
return contracts
}
export const doUserFeedUpdate = async (user: User, contracts: Contract[]) => {
const userCacheCollection = firestore.collection(
`private-users/${user.id}/cache`
)
}
)
const getUserCacheCollection = (user: User) =>
firestore.collection(`private-users/${user.id}/cache`)
export const computeFeed = async (user: User, contracts: Contract[]) => {
const userCacheCollection = getUserCacheCollection(user)
const [wordScores, lastViewedTime] = await Promise.all([
getValue<{ [word: string]: number }>(userCacheCollection.doc('wordScores')),
getValue<{ [contractId: string]: number }>(
@ -109,8 +141,7 @@ export const doUserFeedUpdate = async (user: User, contracts: Contract[]) => {
const feed = await Promise.all(
feedContracts.map((contract) => getRecentBetsAndComments(contract))
)
await userCacheCollection.doc('feed').set({ feed })
return feed
}
function scoreContract(
@ -180,31 +211,3 @@ function getLastViewedScore(viewTime: number | undefined) {
const frac = logInterpolation(0.5, 14, daysAgo)
return 0.75 + 0.25 * frac
}
async function getRecentBetsAndComments(contract: Contract) {
const contractDoc = firestore.collection('contracts').doc(contract.id)
const [recentBets, recentComments] = await Promise.all([
getValues<Bet>(
contractDoc
.collection('bets')
.where('createdTime', '>', Date.now() - DAY_MS)
.orderBy('createdTime', 'desc')
.limit(1)
),
getValues<Comment>(
contractDoc
.collection('comments')
.where('createdTime', '>', Date.now() - 3 * DAY_MS)
.orderBy('createdTime', 'desc')
.limit(3)
),
])
return {
contract,
recentBets,
recentComments,
}
}

View File

@ -303,6 +303,7 @@ function BuyPanel(props: {
<>
<YesNoSelector
className="mb-4"
btnClassName="flex-1"
selected={betChoice}
onSelect={(choice) => onBetChoice(choice)}
/>

View File

@ -15,9 +15,9 @@ import { useSaveShares } from './use-save-shares'
export default function BetRow(props: {
contract: FullContract<DPM | CPMM, Binary>
className?: string
labelClassName?: string
btnClassName?: string
}) {
const { className, labelClassName, contract } = props
const { className, btnClassName, contract } = props
const [open, setOpen] = useState(false)
const [betChoice, setBetChoice] = useState<'YES' | 'NO' | undefined>(
undefined
@ -31,12 +31,9 @@ export default function BetRow(props: {
return (
<>
<Row className={clsx('mt-2 justify-end space-x-3', className)}>
{/* <div className={clsx('mr-2 text-gray-400', labelClassName)}>
Place a trade
</div> */}
<YesNoSelector
btnClassName="btn-sm w-24"
className={clsx('mt-2 justify-end', className)}
btnClassName={clsx('btn-sm w-24', btnClassName)}
onSelect={(choice) => {
setOpen(true)
setBetChoice(choice)
@ -62,7 +59,6 @@ export default function BetRow(props: {
) : undefined
}
/>
</Row>
<Modal open={open} setOpen={setOpen}>
<BetPanelSwitcher
contract={contract}

View File

@ -2,6 +2,7 @@ import clsx from 'clsx'
import dayjs from 'dayjs'
import { useState } from 'react'
import Textarea from 'react-expanding-textarea'
import { CATEGORY_LIST } from '../../../common/categories'
import { Contract } from 'common/contract'
import { parseTags } from 'common/util/parse'
@ -9,6 +10,7 @@ import { useAdmin } from 'web/hooks/use-admin'
import { updateContract } from 'web/lib/firebase/contracts'
import { Row } from '../layout/row'
import { Linkify } from '../linkify'
import { TagsList } from '../tags-list'
export function ContractDescription(props: {
contract: Contract
@ -26,6 +28,7 @@ export function ContractDescription(props: {
`${newDescription} ${contract.tags.map((tag) => `#${tag}`).join(' ')}`
)
const lowercaseTags = tags.map((tag) => tag.toLowerCase())
await updateContract(contract.id, {
description: newDescription,
tags,
@ -35,6 +38,9 @@ export function ContractDescription(props: {
if (!isCreator && !contract.description.trim()) return null
const { tags } = contract
const category = tags.find((tag) => CATEGORY_LIST.includes(tag.toLowerCase()))
return (
<div
className={clsx(
@ -43,7 +49,15 @@ export function ContractDescription(props: {
)}
>
<Linkify text={contract.description} />
{category && (
<div className="mt-4">
<TagsList tags={[category]} label="Category" />
</div>
)}
<br />
{isCreator && (
<EditContract
// Note: Because descriptionTimestamp is called once, later edits use

View File

@ -60,9 +60,7 @@ export const ContractOverview = (props: {
<Row className="items-center justify-between gap-4 xl:hidden">
<BinaryResolutionOrChance contract={contract} />
{tradingAllowed(contract) && (
<BetRow contract={contract} labelClassName="hidden" />
)}
{tradingAllowed(contract) && <BetRow contract={contract} />}
</Row>
) : (
outcomeType === 'FREE_RESPONSE' &&

View File

@ -20,11 +20,11 @@ export function ActivityFeed(props: {
const user = useUser()
return (
<Col className="divide-y divide-gray-300 bg-white">
<Col className="gap-2">
{feed.map((item) => (
<ContractActivity
key={item.contract.id}
className="py-6 px-2 sm:px-4"
className="rounded-md bg-white py-6 px-2 sm:px-4"
user={user}
contract={item.contract}
bets={item.recentBets}

View File

@ -1,4 +1,4 @@
import _ from 'lodash'
import _, { Dictionary } from 'lodash'
import { Answer } from 'common/answer'
import { Bet } from 'common/bet'
@ -18,6 +18,7 @@ export type ActivityItem =
| CloseItem
| ResolveItem
| CommentInputItem
| CommentThreadItem
type BaseActivityItem = {
id: string
@ -53,9 +54,15 @@ export type CommentItem = BaseActivityItem & {
type: 'comment'
comment: Comment
betsBySameUser: Bet[]
hideOutcome: boolean
truncate: boolean
smallAvatar: boolean
truncate?: boolean
smallAvatar?: boolean
}
export type CommentThreadItem = BaseActivityItem & {
type: 'commentThread'
parentComment: Comment
comments: Comment[]
betsByUserId: Dictionary<[Bet, ...Bet[]]>
}
export type BetGroupItem = BaseActivityItem & {
@ -68,6 +75,8 @@ export type AnswerGroupItem = BaseActivityItem & {
type: 'answergroup' | 'answer'
answer: Answer
items: ActivityItem[]
betsByCurrentUser?: Bet[]
comments?: Comment[]
}
export type CloseItem = BaseActivityItem & {
@ -131,7 +140,6 @@ function groupBets(
comment,
betsBySameUser: [bet],
contract,
hideOutcome,
truncate: abbreviated,
smallAvatar,
}
@ -273,41 +281,21 @@ function getAnswerAndCommentInputGroups(
getOutcomeProbability(contract, outcome)
)
function collateCommentsSectionForOutcome(outcome: string) {
const answerBets = bets.filter((bet) => bet.outcome === outcome)
const answerComments = comments.filter(
(comment) =>
comment.answerOutcome === outcome ||
answerBets.some((bet) => bet.id === comment.betId)
)
let items = []
items.push({
type: 'commentInput' as const,
id: 'commentInputFor' + outcome,
contract,
betsByCurrentUser: user
? bets.filter((bet) => bet.userId === user.id)
: [],
comments: comments,
answerOutcome: outcome,
})
items.push(
...getCommentsWithPositions(
answerBets,
answerComments,
contract
).reverse()
)
return items
}
const answerGroups = outcomes
.map((outcome) => {
const answer = contract.answers?.find(
(answer) => answer.id === outcome
) as Answer
const items = collateCommentsSectionForOutcome(outcome)
const answerBets = bets.filter((bet) => bet.outcome === outcome)
const answerComments = comments.filter(
(comment) =>
comment.answerOutcome === outcome ||
answerBets.some((bet) => bet.id === comment.betId)
)
const items = getCommentThreads(answerBets, answerComments, contract)
if (outcome === GENERAL_COMMENTS_OUTCOME_ID) items.reverse()
return {
id: outcome,
@ -316,6 +304,8 @@ function getAnswerAndCommentInputGroups(
answer,
items,
user,
betsByCurrentUser: answerBets.filter((bet) => bet.userId === user?.id),
comments: answerComments,
}
})
.filter((group) => group.answer) as ActivityItem[]
@ -344,7 +334,6 @@ function groupBetsAndComments(
comment,
betsBySameUser: [],
truncate: abbreviated,
hideOutcome: true,
smallAvatar,
}))
@ -370,22 +359,21 @@ function groupBetsAndComments(
return abbrItems
}
function getCommentsWithPositions(
function getCommentThreads(
bets: Bet[],
comments: Comment[],
contract: Contract
) {
const betsByUserId = _.groupBy(bets, (bet) => bet.userId)
const parentComments = comments.filter((comment) => !comment.replyToCommentId)
const items = comments.map((comment) => ({
type: 'comment' as const,
const items = parentComments.map((comment) => ({
type: 'commentThread' as const,
id: comment.id,
contract: contract,
comment,
betsBySameUser: bets.length === 0 ? [] : betsByUserId[comment.userId] ?? [],
truncate: false,
hideOutcome: false,
smallAvatar: false,
comments: comments,
parentComment: comment,
betsByUserId: betsByUserId,
}))
return items
@ -546,6 +534,8 @@ export function getSpecificContractActivityItems(
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,
@ -566,7 +556,7 @@ export function getSpecificContractActivityItems(
const nonFreeResponseBets =
contract.outcomeType === 'FREE_RESPONSE' ? [] : bets
items.push(
...getCommentsWithPositions(
...getCommentThreads(
nonFreeResponseBets,
nonFreeResponseComments,
contract

View File

@ -0,0 +1,75 @@
import clsx from 'clsx'
import _ from 'lodash'
import { User } from '../../../common/user'
import { Row } from '../layout/row'
import { CATEGORIES, CATEGORY_LIST } from '../../../common/categories'
import { updateUser } from '../../lib/firebase/users'
export function CategorySelector(props: {
user: User | null | undefined
className?: string
}) {
const { className, user } = props
const followedCategories = user?.followedCategories ?? []
return (
<Row
className={clsx(
'mr-2 items-center space-x-2 space-y-2 overflow-x-scroll scroll-smooth pt-4 pb-4 sm:flex-wrap',
className
)}
>
<div />
<CategoryButton
key={'all' + followedCategories.length}
category="All"
isFollowed={followedCategories.length === 0}
toggle={async () => {
if (!user?.id) return
await updateUser(user.id, {
followedCategories: [],
})
}}
/>
{CATEGORY_LIST.map((cat) => (
<CategoryButton
key={cat + followedCategories.length}
category={CATEGORIES[cat].split(' ')[0]}
isFollowed={followedCategories.includes(cat)}
toggle={async () => {
if (!user?.id) return
await updateUser(user.id, {
followedCategories: [cat],
})
}}
/>
))}
</Row>
)
}
function CategoryButton(props: {
category: string
isFollowed: boolean
toggle: () => void
}) {
const { toggle, category, isFollowed } = props
return (
<div
className={clsx(
'rounded-full border-2 px-4 py-1 shadow-md hover:bg-gray-200',
'cursor-pointer select-none',
isFollowed ? 'border-gray-300 bg-gray-300' : 'bg-white'
)}
onClick={toggle}
>
<span className="text-sm text-gray-500">{category}</span>
</div>
)
}

View File

@ -10,6 +10,7 @@ import {
} from './activity-items'
import { FeedItems } from './feed-items'
import { User } from 'common/user'
import { useContract } from 'web/hooks/use-contract'
export function ContractActivity(props: {
contract: Contract
@ -27,8 +28,9 @@ export function ContractActivity(props: {
className?: string
betRowClassName?: string
}) {
const { contract, user, mode, contractPath, className, betRowClassName } =
props
const { user, mode, contractPath, className, betRowClassName } = props
const contract = useContract(props.contract.id) ?? props.contract
const updatedComments =
// eslint-disable-next-line react-hooks/rules-of-hooks

View File

@ -1,6 +1,7 @@
// From https://tailwindui.com/components/application-ui/lists/feeds
import React, { Fragment, useRef, useState } from 'react'
import React, { Fragment, useEffect, useRef, useState } from 'react'
import * as _ from 'lodash'
import { Dictionary } from 'lodash'
import {
BanIcon,
CheckIcon,
@ -15,8 +16,8 @@ import Textarea from 'react-expanding-textarea'
import { OutcomeLabel } from '../outcome-label'
import {
contractMetrics,
Contract,
contractMetrics,
contractPath,
tradingAllowed,
} from 'web/lib/firebase/contracts'
@ -30,15 +31,13 @@ import { BinaryResolutionOrChance } from '../contract/contract-card'
import { SiteLink } from '../site-link'
import { Col } from '../layout/col'
import { UserLink } from '../user-page'
import { DateTimeTooltip } from '../datetime-tooltip'
import { Bet } from 'web/lib/firebase/bets'
import { JoinSpans } from '../join-spans'
import { fromNow } from 'web/lib/util/time'
import BetRow from '../bet-row'
import { Avatar } from '../avatar'
import { Answer } from 'common/answer'
import { ActivityItem, GENERAL_COMMENTS_OUTCOME_ID } from './activity-items'
import { Binary, CPMM, DPM, FreeResponse, FullContract } from 'common/contract'
import { Binary, CPMM, FreeResponse, FullContract } from 'common/contract'
import { BuyButton } from '../yes-no-selector'
import { getDpmOutcomeProbability } from 'common/calculate-dpm'
import { AnswerBetPanel } from '../answers/answer-bet-panel'
@ -118,24 +117,97 @@ function FeedItem(props: { item: ActivityItem }) {
return <FeedResolve {...item} />
case 'commentInput':
return <CommentInput {...item} />
case 'commentThread':
return <FeedCommentThread {...item} />
}
}
export function FeedCommentThread(props: {
contract: Contract
comments: Comment[]
parentComment: Comment
betsByUserId: Dictionary<[Bet, ...Bet[]]>
truncate?: boolean
smallAvatar?: boolean
}) {
const {
contract,
comments,
betsByUserId,
truncate,
smallAvatar,
parentComment,
} = props
const [showReply, setShowReply] = useState(false)
const [replyToUsername, setReplyToUsername] = useState('')
const user = useUser()
const commentsList = comments.filter(
(comment) => comment.replyToCommentId === parentComment.id
)
commentsList.unshift(parentComment)
const [inputRef, setInputRef] = useState<HTMLTextAreaElement | null>(null)
function scrollAndOpenReplyInput(comment: Comment) {
setReplyToUsername(comment.userUsername)
setShowReply(true)
inputRef?.focus()
}
useEffect(() => {
if (showReply && inputRef) inputRef.focus()
}, [inputRef, showReply])
return (
<div className={'w-full flex-col flex-col pr-6'}>
{commentsList.map((comment, commentIdx) => (
<div
key={comment.id}
id={comment.id}
className={clsx(
'flex space-x-3',
commentIdx === 0 ? '' : 'mt-4 ml-8'
)}
>
<FeedComment
contract={contract}
comment={comment}
betsBySameUser={betsByUserId[comment.userId] ?? []}
onReplyClick={scrollAndOpenReplyInput}
smallAvatar={smallAvatar}
truncate={truncate}
/>
</div>
))}
{showReply && (
<div className={'ml-8 w-full pt-6'}>
<CommentInput
contract={contract}
// Should we allow replies to contain recent bet info?
betsByCurrentUser={(user && betsByUserId[user.id]) ?? []}
comments={comments}
parentComment={parentComment}
replyToUsername={replyToUsername}
answerOutcome={comments[0].answerOutcome}
setRef={setInputRef}
/>
</div>
)}
</div>
)
}
export function FeedComment(props: {
contract: Contract
comment: Comment
betsBySameUser: Bet[]
hideOutcome: boolean
truncate: boolean
smallAvatar: boolean
truncate?: boolean
smallAvatar?: boolean
onReplyClick?: (comment: Comment) => void
}) {
const {
contract,
comment,
betsBySameUser,
hideOutcome,
truncate,
smallAvatar,
onReplyClick,
} = props
const { text, userUsername, userName, userAvatarUrl, createdTime } = comment
let outcome: string | undefined,
@ -187,7 +259,7 @@ export function FeedComment(props: {
)}
<>
{bought} {money}
{outcome && !hideOutcome && (
{contract.outcomeType !== 'FREE_RESPONSE' && outcome && (
<>
{' '}
of{' '}
@ -206,6 +278,14 @@ export function FeedComment(props: {
moreHref={contractPath(contract)}
shouldTruncate={truncate}
/>
{onReplyClick && (
<button
className={'text-xs font-bold text-gray-500 hover:underline'}
onClick={() => onReplyClick(comment)}
>
Reply
</button>
)}
</div>
</>
)
@ -215,59 +295,79 @@ export function CommentInput(props: {
contract: Contract
betsByCurrentUser: Bet[]
comments: Comment[]
// Only for free response comment inputs
// Tie a comment to an free response answer outcome
answerOutcome?: string
// Tie a comment to another comment
parentComment?: Comment
replyToUsername?: string
setRef?: (ref: HTMLTextAreaElement) => void
}) {
const { contract, betsByCurrentUser, comments, answerOutcome } = props
const {
contract,
betsByCurrentUser,
comments,
answerOutcome,
parentComment,
replyToUsername,
setRef,
} = props
const user = useUser()
const [comment, setComment] = useState('')
const [focused, setFocused] = useState(false)
// Should this be oldest bet or most recent bet?
const mostRecentCommentableBet = betsByCurrentUser
.filter((bet) => {
if (
canCommentOnBet(bet, user) &&
// The bet doesn't already have a comment
!comments.some((comment) => comment.betId == bet.id)
) {
if (!answerOutcome) return true
// If we're in free response, don't allow commenting on ante bet
return (
bet.outcome !== GENERAL_COMMENTS_OUTCOME_ID &&
answerOutcome === bet.outcome
const mostRecentCommentableBet = getMostRecentCommentableBet(
betsByCurrentUser,
comments,
user,
answerOutcome
)
}
return false
})
.sort((b1, b2) => b1.createdTime - b2.createdTime)
.pop()
const { id } = mostRecentCommentableBet || { id: undefined }
useEffect(() => {
if (!replyToUsername || !user || replyToUsername === user.username) return
const replacement = `@${replyToUsername} `
setComment(replacement + comment.replace(replacement, ''))
}, [user, replyToUsername])
async function submitComment(betId: string | undefined) {
if (!user) {
return await firebaseLogin()
}
if (!comment) return
await createComment(contract.id, comment, user, betId, answerOutcome)
// Update state asap to avoid double submission.
const commentValue = comment.toString()
setComment('')
await createComment(
contract.id,
commentValue,
user,
betId,
answerOutcome,
parentComment?.id
)
}
const { userPosition, userPositionMoney, yesFloorShares, noFloorShares } =
getBettorsPosition(contract, Date.now(), betsByCurrentUser)
const shouldCollapseAfterClickOutside = false
return (
<>
<Row className={'flex w-full gap-2'}>
<Row className={'mb-2 flex w-full gap-2'}>
<div className={'mt-1'}>
<Avatar avatarUrl={user?.avatarUrl} username={user?.username} />
</div>
<div className={'min-w-0 flex-1'}>
<div className="text-sm text-gray-500">
<div className={'mb-1'}>
{mostRecentCommentableBet && (
<BetStatusText
contract={contract}
bet={mostRecentCommentableBet}
isSelf={true}
hideOutcome={contract.outcomeType === 'FREE_RESPONSE'}
/>
)}
{!mostRecentCommentableBet && user && userPosition > 0 && (
@ -283,67 +383,73 @@ export function CommentInput(props: {
</>
</>
)}
{(answerOutcome === undefined || focused) && (
<div className="mt-2">
</div>
<Row className="gap-1.5">
<Textarea
ref={(ref: HTMLTextAreaElement) => setRef?.(ref)}
value={comment}
onChange={(e) => setComment(e.target.value)}
className="textarea textarea-bordered w-full resize-none"
placeholder="Add a comment..."
placeholder={
parentComment || answerOutcome
? 'Write a reply... '
: 'Write a comment...'
}
autoFocus={focused}
rows={answerOutcome == undefined || focused ? 3 : 1}
rows={focused ? 3 : 1}
onFocus={() => setFocused(true)}
onBlur={() => !comment && setFocused(false)}
onBlur={() =>
shouldCollapseAfterClickOutside && setFocused(false)
}
maxLength={MAX_COMMENT_LENGTH}
onKeyDown={(e) => {
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
e.preventDefault()
submitComment(id)
}
}}
/>
</div>
<div
className={clsx(
'flex justify-center',
focused ? 'items-end' : 'items-center'
)}
</div>
>
{!user && (
<button
className={
'btn btn-outline btn-sm text-transform: mt-1 capitalize'
'btn btn-outline btn-sm text-transform: capitalize'
}
onClick={() => submitComment(id)}
>
Sign in to Comment
</button>
)}
{user && answerOutcome === undefined && (
{user && (
<button
className={
'btn btn-outline btn-sm text-transform: mt-1 capitalize'
}
onClick={() => submitComment(id)}
>
Comment
</button>
className={clsx(
'btn text-transform: block capitalize',
focused && comment
? 'btn-outline btn-sm '
: 'btn-ghost btn-sm text-gray-500'
)}
{user && answerOutcome !== undefined && (
<button
className={
focused
? 'btn btn-outline btn-sm text-transform: mt-1 capitalize'
: 'btn btn-ghost btn-sm text-transform: mt-1 capitalize'
}
onClick={() => {
if (!focused) setFocused(true)
if (!focused) return
else {
submitComment(id)
setFocused(false)
}
}}
>
{!focused ? 'Add Comment' : 'Comment'}
{parentComment || answerOutcome ? 'Reply' : 'Comment'}
</button>
)}
</div>
</Row>
</div>
</div>
</Row>
</>
)
}
@ -435,8 +541,8 @@ export function FeedBet(props: {
bet={bet}
contract={contract}
isSelf={isSelf}
hideOutcome={hideOutcome}
bettor={bettor}
hideOutcome={hideOutcome}
/>
</div>
</Row>
@ -448,10 +554,10 @@ function BetStatusText(props: {
contract: Contract
bet: Bet
isSelf: boolean
hideOutcome?: boolean
bettor?: User
hideOutcome?: boolean
}) {
const { bet, contract, hideOutcome, bettor, isSelf } = props
const { bet, contract, bettor, isSelf, hideOutcome } = props
const { amount, outcome, createdTime } = bet
const bought = amount >= 0 ? 'bought' : 'sold'
@ -581,6 +687,32 @@ export function FeedQuestion(props: {
)
}
function getMostRecentCommentableBet(
betsByCurrentUser: Bet[],
comments: Comment[],
user?: User | null,
answerOutcome?: string
) {
return betsByCurrentUser
.filter((bet) => {
if (
canCommentOnBet(bet, user) &&
// The bet doesn't already have a comment
!comments.some((comment) => comment.betId == bet.id)
) {
if (!answerOutcome) return true
// If we're in free response, don't allow commenting on ante bet
return (
bet.outcome !== GENERAL_COMMENTS_OUTCOME_ID &&
answerOutcome === bet.outcome
)
}
return false
})
.sort((b1, b2) => b1.createdTime - b2.createdTime)
.pop()
}
function canCommentOnBet(bet: Bet, user?: User | null) {
const { userId, createdTime, isRedemption } = bet
const isSelf = user?.id === userId
@ -768,13 +900,35 @@ function FeedAnswerGroup(props: {
answer: Answer
items: ActivityItem[]
type: string
betsByCurrentUser?: Bet[]
comments?: Comment[]
}) {
const { answer, items, contract, type } = props
const { answer, items, contract, type, betsByCurrentUser, comments } = props
const { username, avatarUrl, name, text } = answer
const user = useUser()
const mostRecentCommentableBet = getMostRecentCommentableBet(
betsByCurrentUser ?? [],
comments ?? [],
user,
answer.number + ''
)
const prob = getDpmOutcomeProbability(contract.totalShares, answer.id)
const probPercent = formatPercent(prob)
const [open, setOpen] = useState(false)
const [showReply, setShowReply] = useState(false)
const isFreeResponseContractPage = type === 'answergroup' && comments
if (mostRecentCommentableBet && !showReply) setShowReplyAndFocus(true)
const [inputRef, setInputRef] = useState<HTMLTextAreaElement | null>(null)
// If they've already opened the input box, focus it once again
function setShowReplyAndFocus(show: boolean) {
setShowReply(show)
inputRef?.focus()
}
useEffect(() => {
if (showReply && inputRef) inputRef.focus()
}, [inputRef, showReply])
return (
<Col
@ -794,11 +948,17 @@ function FeedAnswerGroup(props: {
/>
</Modal>
{type == 'answer' && (
<div
className="absolute -m-3 h-full rounded-tl-md bg-green-600 bg-opacity-10"
style={{ width: `${100 * Math.max(prob, 0.01)}%` }}
></div>
)}
<Row className="my-4 gap-3">
<div className="px-1">
<Avatar username={username} avatarUrl={avatarUrl} />
</div>
<Col className="min-w-0 flex-1 gap-2">
<Col className="min-w-0 flex-1 lg:gap-1">
<div className="text-sm text-gray-500">
<UserLink username={username} name={name} /> answered
</div>
@ -808,11 +968,25 @@ function FeedAnswerGroup(props: {
<Linkify text={text} />
</span>
<Row className="align-items justify-end gap-4">
<Row className="items-center justify-center gap-4">
{isFreeResponseContractPage && (
<div className={'sm:hidden'}>
<button
className={
'text-xs font-bold text-gray-500 hover:underline'
}
onClick={() => setShowReplyAndFocus(true)}
>
Reply
</button>
</div>
)}
<div className={'align-items flex w-full justify-end gap-4 '}>
<span
className={clsx(
'text-2xl',
tradingAllowed(contract) ? 'text-green-500' : 'text-gray-500'
tradingAllowed(contract) ? 'text-primary' : 'text-gray-500'
)}
>
{probPercent}
@ -824,8 +998,19 @@ function FeedAnswerGroup(props: {
)}
onClick={() => setOpen(true)}
/>
</div>
</Row>
</Col>
{isFreeResponseContractPage && (
<div className={'justify-initial hidden sm:block'}>
<button
className={'text-xs font-bold text-gray-500 hover:underline'}
onClick={() => setShowReplyAndFocus(true)}
>
Reply
</button>
</div>
)}
</Col>
</Row>
@ -848,6 +1033,19 @@ function FeedAnswerGroup(props: {
</div>
</div>
))}
{showReply && (
<div className={'ml-8 pt-4'}>
<CommentInput
contract={contract}
betsByCurrentUser={betsByCurrentUser ?? []}
comments={comments ?? []}
answerOutcome={answer.number + ''}
replyToUsername={answer.username}
setRef={setInputRef}
/>
</div>
)}
</Col>
)
}

View File

@ -15,9 +15,9 @@ export function TagsInput(props: { contract: Contract; className?: string }) {
const [isSubmitting, setIsSubmitting] = useState(false)
const updateTags = () => {
const updateTags = async () => {
setIsSubmitting(true)
updateContract(contract.id, {
await updateContract(contract.id, {
tags: newTags,
lowercaseTags: newTags.map((tag) => tag.toLowerCase()),
})
@ -37,6 +37,12 @@ export function TagsInput(props: { contract: Contract; className?: string }) {
disabled={isSubmitting}
value={tagText}
onChange={(e) => setTagText(e.target.value || '')}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
updateTags()
}
}}
/>
<button className="btn btn-xs btn-outline" onClick={updateTags}>
Save tags

View File

@ -1,4 +1,5 @@
import clsx from 'clsx'
import { CATEGORIES } from '../../common/categories'
import { Col } from './layout/col'
import { Row } from './layout/row'
@ -6,6 +7,8 @@ import { SiteLink } from './site-link'
function Hashtag(props: { tag: string; noLink?: boolean }) {
const { tag, noLink } = props
const category = CATEGORIES[tag.replace('#', '').toLowerCase()]
const body = (
<div
className={clsx(
@ -13,7 +16,7 @@ function Hashtag(props: { tag: string; noLink?: boolean }) {
!noLink && 'cursor-pointer'
)}
>
<span className="text-sm text-gray-600">{tag}</span>
<span className="text-sm text-gray-600">{category ?? tag}</span>
</div>
)
@ -30,11 +33,12 @@ export function TagsList(props: {
className?: string
noLink?: boolean
noLabel?: boolean
label?: string
}) {
const { tags, className, noLink, noLabel } = props
const { tags, className, noLink, noLabel, label } = props
return (
<Row className={clsx('flex-wrap items-center gap-2', className)}>
{!noLabel && <div className="mr-1 text-gray-500">Tags</div>}
{!noLabel && <div className="mr-1 text-gray-500">{label || 'Tags'}</div>}
{tags.map((tag) => (
<Hashtag
key={tag}

View File

@ -22,7 +22,7 @@ export function YesNoSelector(props: {
} = props
const commonClassNames =
'inline-flex flex-1 items-center justify-center rounded-3xl border-2 p-2'
'inline-flex items-center justify-center rounded-3xl border-2 p-2'
return (
<Row className={clsx('space-x-3', className)}>

View File

@ -1,26 +1,18 @@
import _ from 'lodash'
import _, { Dictionary } from 'lodash'
import { useState, useEffect } from 'react'
import { Bet } from 'common/bet'
import { Comment } from 'common/comment'
import { Contract } from 'common/contract'
import type { feed } from 'common/feed'
import { useTimeSinceFirstRender } from './use-time-since-first-render'
import { trackLatency } from 'web/lib/firebase/tracking'
import { User } from 'common/user'
import { getUserFeed } from 'web/lib/firebase/users'
import { useUpdatedContracts } from './use-contracts'
import { getCategoryFeeds, getUserFeed } from 'web/lib/firebase/users'
import {
getRecentBetsAndComments,
getTopWeeklyContracts,
} from 'web/lib/firebase/contracts'
type feed = {
contract: Contract
recentBets: Bet[]
recentComments: Comment[]
}[]
export const useAlgoFeed = (user: User | null | undefined) => {
const [feed, setFeed] = useState<feed>()
const [categoryFeeds, setCategoryFeeds] = useState<Dictionary<feed>>()
const getTime = useTimeSinceFirstRender()
@ -34,21 +26,21 @@ export const useAlgoFeed = (user: User | null | undefined) => {
trackLatency('feed', getTime())
console.log('feed load time', getTime())
})
getCategoryFeeds(user.id).then((feeds) => {
setCategoryFeeds(feeds)
console.log('category feeds load time', getTime())
})
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [user?.id])
return useUpdateFeed(feed)
}
const followedCategory = user?.followedCategories?.[0] ?? 'all'
const useUpdateFeed = (feed: feed | undefined) => {
const contracts = useUpdatedContracts(feed?.map((item) => item.contract))
const followedFeed =
followedCategory === 'all' ? feed : categoryFeeds?.[followedCategory]
return feed && contracts
? feed.map(({ contract, ...other }, i) => ({
...other,
contract: contracts[i],
}))
: undefined
return followedFeed
}
const getDefaultFeed = async () => {

View File

@ -1,17 +1,21 @@
import { useEffect, useState } from 'react'
import { Contract, listenForContract } from 'web/lib/firebase/contracts'
import { useEffect } from 'react'
import { useFirestoreDocumentData } from '@react-query-firebase/firestore'
import {
Contract,
contractDocRef,
listenForContract,
} from 'web/lib/firebase/contracts'
import { useStateCheckEquality } from './use-state-check-equality'
import { DocumentData } from 'firebase/firestore'
export const useContract = (contractId: string) => {
const [contract, setContract] = useState<Contract | null | 'loading'>(
'loading'
const result = useFirestoreDocumentData<DocumentData, Contract>(
['contracts', contractId],
contractDocRef(contractId),
{ subscribe: true, includeMetadataChanges: true }
)
useEffect(() => {
if (contractId) return listenForContract(contractId, setContract)
}, [contractId])
return contract
return result.isLoading ? undefined : result.data
}
export const useContractWithPreload = (initial: Contract | null) => {

View File

@ -13,6 +13,7 @@ import { getValues, listenForValues } from './utils'
import { db } from './init'
import { User } from 'common/user'
import { Comment } from 'common/comment'
import { removeUndefinedProps } from 'common/util/object'
export type { Comment }
@ -23,12 +24,13 @@ export async function createComment(
text: string,
commenter: User,
betId?: string,
answerOutcome?: string
answerOutcome?: string,
replyToCommentId?: string
) {
const ref = betId
? doc(getCommentsCollection(contractId), betId)
: doc(getCommentsCollection(contractId))
const comment: Comment = {
const comment: Comment = removeUndefinedProps({
id: ref.id,
contractId,
userId: commenter.id,
@ -37,13 +39,10 @@ export async function createComment(
userName: commenter.name,
userUsername: commenter.username,
userAvatarUrl: commenter.avatarUrl,
}
if (betId) {
comment.betId = betId
}
if (answerOutcome) {
comment.answerOutcome = answerOutcome
}
betId: betId,
answerOutcome: answerOutcome,
replyToCommentId: replyToCommentId,
})
return await setDoc(ref, comment)
}

View File

@ -79,6 +79,8 @@ export function tradingAllowed(contract: Contract) {
const db = getFirestore(app)
export const contractCollection = collection(db, 'contracts')
export const contractDocRef = (contractId: string) =>
doc(db, 'contracts', contractId)
// Push contract to Firestore
export async function setContract(contract: Contract) {

View File

@ -25,9 +25,8 @@ import { PrivateUser, User } from 'common/user'
import { createUser } from './api-call'
import { getValue, getValues, listenForValue, listenForValues } from './utils'
import { DAY_MS } from 'common/util/time'
import { Contract } from './contracts'
import { Bet } from './bets'
import { Comment } from './comments'
import { feed } from 'common/feed'
import { CATEGORY_LIST } from 'common/categories'
export type { User }
@ -216,11 +215,18 @@ export async function getDailyNewUsers(
export async function getUserFeed(userId: string) {
const feedDoc = doc(db, 'private-users', userId, 'cache', 'feed')
const userFeed = await getValue<{
feed: {
contract: Contract
recentBets: Bet[]
recentComments: Comment[]
}[]
feed: feed
}>(feedDoc)
return userFeed?.feed ?? []
}
export async function getCategoryFeeds(userId: string) {
const cacheCollection = collection(db, 'private-users', userId, 'cache')
const feedData = await Promise.all(
CATEGORY_LIST.map((category) =>
getValue<{ feed: feed }>(doc(cacheCollection, `feed-${category}`))
)
)
const feeds = feedData.map((data) => data?.feed ?? [])
return _.fromPairs(_.zip(CATEGORY_LIST, feeds) as [string, feed][])
}

View File

@ -316,7 +316,6 @@ function ContractTopTrades(props: {
contract={contract}
comment={commentsById[topCommentId]}
betsBySameUser={[betsById[topCommentId]]}
hideOutcome={false}
truncate={false}
smallAvatar={false}
/>

View File

@ -4,6 +4,7 @@ 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'
function firstLine(msg: string) {
return msg.replace(/\r?\n.*/s, '')
@ -77,9 +78,13 @@ function MyApp({ Component, pageProps }: AppProps) {
/>
</Head>
<QueryClientProvider client={queryClient}>
<Component {...pageProps} />
</QueryClientProvider>
</>
)
}
const queryClient = new QueryClient()
export default MyApp

View File

@ -13,13 +13,12 @@ import { InfoTooltip } from 'web/components/info-tooltip'
import { Page } from 'web/components/page'
import { Title } from 'web/components/title'
import { ProbabilitySelector } from 'web/components/probability-selector'
import { parseWordsAsTags } from 'common/util/parse'
import { TagsList } from 'web/components/tags-list'
import { Row } from 'web/components/layout/row'
import { MAX_DESCRIPTION_LENGTH, outcomeType } from 'common/contract'
import { formatMoney } from 'common/util/format'
import { useHasCreatedContractToday } from 'web/hooks/use-has-created-contract-today'
import { removeUndefinedProps } from 'common/util/object'
import { removeUndefinedProps } from '../../common/util/object'
import { CATEGORIES, CATEGORY_LIST, TO_CATEGORY } from 'common/categories'
export default function Create() {
const [question, setQuestion] = useState('')
@ -70,8 +69,10 @@ export function NewContract(props: { question: string; tag?: string }) {
const [minString, setMinString] = useState('')
const [maxString, setMaxString] = useState('')
const [description, setDescription] = useState('')
const [tagText, setTagText] = useState<string>(tag ?? '')
const tags = parseWordsAsTags(tagText)
const [category, setCategory] = useState<string>('')
// const [tagText, setTagText] = useState<string>(tag ?? '')
// const tags = parseWordsAsTags(tagText)
const [ante, setAnte] = useState(FIXED_ANTE)
@ -126,7 +127,7 @@ export function NewContract(props: { question: string; tag?: string }) {
initialProb,
ante,
closeTime,
tags,
tags: category ? [category] : undefined,
min,
max,
})
@ -256,25 +257,29 @@ export function NewContract(props: { question: string; tag?: string }) {
/>
</div>
{/* <Spacer h={4} />
<Spacer h={4} />
<div className="form-control max-w-sm items-start">
<label className="label gap-2">
<span className="mb-1">Tags</span>
<InfoTooltip text="Optional. Help categorize your market with related tags." />
<span className="mb-1">Category</span>
</label>
<input
placeholder="e.g. Politics, Economics..."
className="input input-bordered resize-none"
disabled={isSubmitting}
value={tagText}
onChange={(e) => setTagText(e.target.value || '')}
/>
</div> */}
<select
className="select select-bordered w-full max-w-xs"
onChange={(e) =>
setCategory(TO_CATEGORY[e.currentTarget.value] ?? '')
}
>
<option selected={category === ''}></option>
{CATEGORY_LIST.map((cat) => (
<option selected={category === cat} value={CATEGORIES[cat]}>
{CATEGORIES[cat]}
</option>
))}
</select>
</div>
<Spacer h={4} />
<TagsList tags={tags} noLink noLabel />
<Spacer h={4} />
<div className="form-control mb-1 items-start">

View File

@ -11,6 +11,7 @@ import { useUser } from 'web/hooks/use-user'
import { LoadingIndicator } from 'web/components/loading-indicator'
import { useAlgoFeed } from 'web/hooks/use-algo-feed'
import { ContractPageContent } from './[username]/[contractSlug]'
import { CategorySelector } from '../components/feed/category-selector'
const Home = () => {
const user = useUser()
@ -42,7 +43,12 @@ const Home = () => {
<Col className="items-center">
<Col className="w-full max-w-[700px]">
<FeedCreate user={user ?? undefined} />
<Spacer h={10} />
<Spacer h={2} />
<CategorySelector user={user} />
<Spacer h={1} />
{feed ? (
<ActivityFeed
feed={feed}

View File

@ -140,7 +140,7 @@
core-js-pure "^3.20.2"
regenerator-runtime "^0.13.4"
"@babel/runtime@^7.1.2":
"@babel/runtime@^7.1.2", "@babel/runtime@^7.12.5", "@babel/runtime@^7.5.5", "@babel/runtime@^7.6.2", "@babel/runtime@^7.7.2":
version "7.17.9"
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.17.9.tgz#d19fbf802d01a8cb6cf053a64e472d42c434ba72"
integrity sha512-lSiBBvodq29uShpWGNbgFdKYNiFDo5/HIYsaCEY9ff4sb10x9jizo2+pRrSyF4jKZCXqgzuqBOQKbUm90gQwJg==
@ -958,6 +958,11 @@
resolved "https://registry.yarnpkg.com/@protobufjs/utf8/-/utf8-1.1.0.tgz#a777360b5b39a1a2e5106f8e858f2fd2d060c570"
integrity sha1-p3c2C1s5oaLlEG+OhY8v0tBgxXA=
"@react-query-firebase/firestore@^0.4.2":
version "0.4.2"
resolved "https://registry.yarnpkg.com/@react-query-firebase/firestore/-/firestore-0.4.2.tgz#6ae52768715aa0a5c0d903dd4fd953ed417ba635"
integrity sha512-7eYp905+sfBRcBTdj7W7BAc3bI3V0D0kKca4/juOTnN4gyoNyaCNOCjLPY467dTq325hGs7BX0ol7Pw3JENdHA==
"@react-spring/animated@~9.2.6-beta.0":
version "9.2.6"
resolved "https://registry.yarnpkg.com/@react-spring/animated/-/animated-9.2.6.tgz#58f30fb75d8bfb7ccbc156cfd6b974a8f3dfd54e"
@ -1535,6 +1540,11 @@ base64-js@^1.3.0:
resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a"
integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==
big-integer@^1.6.16:
version "1.6.51"
resolved "https://registry.yarnpkg.com/big-integer/-/big-integer-1.6.51.tgz#0df92a5d9880560d3ff2d5fd20245c889d130686"
integrity sha512-GPEid2Y9QU1Exl1rpO9B2IPJGHPSupF5GnVIP0blYvNOMer2bTvSWs1jGOUg04hTmu67nmLsQ9TBo1puaotBHg==
bignumber.js@^9.0.0:
version "9.0.2"
resolved "https://registry.yarnpkg.com/bignumber.js/-/bignumber.js-9.0.2.tgz#71c6c6bed38de64e24a65ebe16cfcf23ae693673"
@ -1588,6 +1598,20 @@ braces@^3.0.1, braces@~3.0.2:
dependencies:
fill-range "^7.0.1"
broadcast-channel@^3.4.1:
version "3.7.0"
resolved "https://registry.yarnpkg.com/broadcast-channel/-/broadcast-channel-3.7.0.tgz#2dfa5c7b4289547ac3f6705f9c00af8723889937"
integrity sha512-cIAKJXAxGJceNZGTZSBzMxzyOn72cVgPnKx4dc6LRjQgbaJUQqhy5rzL3zbMxkMWsGKkv2hSFkPRMEXfoMZ2Mg==
dependencies:
"@babel/runtime" "^7.7.2"
detect-node "^2.1.0"
js-sha3 "0.8.0"
microseconds "0.2.0"
nano-time "1.0.0"
oblivious-set "1.0.0"
rimraf "3.0.2"
unload "2.2.0"
browserslist@^4.16.6:
version "4.19.1"
resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.19.1.tgz#4ac0435b35ab655896c31d53018b6dd5e9e4c9a3"
@ -2128,6 +2152,11 @@ destroy@~1.0.4:
resolved "https://registry.yarnpkg.com/destroy/-/destroy-1.0.4.tgz#978857442c44749e4206613e37946205826abd80"
integrity sha1-l4hXRCxEdJ5CBmE+N5RiBYJqvYA=
detect-node@^2.0.4, detect-node@^2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/detect-node/-/detect-node-2.1.0.tgz#c9c70775a49c3d03bc2c06d9a73be550f978f8b1"
integrity sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==
detective@^5.2.0:
version "5.2.0"
resolved "https://registry.yarnpkg.com/detective/-/detective-5.2.0.tgz#feb2a77e85b904ecdea459ad897cc90a99bd2a7b"
@ -3475,6 +3504,11 @@ jose@^2.0.5:
dependencies:
"@panva/asn1.js" "^1.0.0"
js-sha3@0.8.0:
version "0.8.0"
resolved "https://registry.yarnpkg.com/js-sha3/-/js-sha3-0.8.0.tgz#b9b7a5da73afad7dedd0f8c463954cbde6818840"
integrity sha512-gF1cRrHhIzNfToc802P800N8PpXS+evLLXfsVpowqmAFR9uwbi89WvXg2QspOmXL8QL86J4T1EpFu+yUkwJY3Q==
"js-tokens@^3.0.0 || ^4.0.0", js-tokens@^4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499"
@ -3827,6 +3861,14 @@ make-dir@^3.0.0:
dependencies:
semver "^6.0.0"
match-sorter@^6.0.2:
version "6.3.1"
resolved "https://registry.yarnpkg.com/match-sorter/-/match-sorter-6.3.1.tgz#98cc37fda756093424ddf3cbc62bfe9c75b92bda"
integrity sha512-mxybbo3pPNuA+ZuCUhm5bwNkXrJTbsk5VWbR5wiwz/GC6LIiegBGn2w3O08UG/jdbYLinw51fSQ5xNU1U3MgBw==
dependencies:
"@babel/runtime" "^7.12.5"
remove-accents "0.4.2"
media-typer@0.3.0:
version "0.3.0"
resolved "https://registry.yarnpkg.com/media-typer/-/media-typer-0.3.0.tgz#8710d7af0aa626f8fffa1ce00168545263255748"
@ -3860,6 +3902,11 @@ micromatch@^4.0.4:
braces "^3.0.1"
picomatch "^2.2.3"
microseconds@0.2.0:
version "0.2.0"
resolved "https://registry.yarnpkg.com/microseconds/-/microseconds-0.2.0.tgz#233b25f50c62a65d861f978a4a4f8ec18797dc39"
integrity sha512-n7DHHMjR1avBbSpsTBj6fmMGh2AGrifVV4e+WYc3Q9lO+xnSZ3NyhcBND3vzzatt05LFhoKFRxrIyklmLlUtyA==
mime-db@1.51.0, "mime-db@>= 1.43.0 < 2":
version "1.51.0"
resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.51.0.tgz#d9ff62451859b18342d960850dc3cfb77e63fb0c"
@ -3957,6 +4004,13 @@ multimatch@^4.0.0:
arrify "^2.0.1"
minimatch "^3.0.4"
nano-time@1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/nano-time/-/nano-time-1.0.0.tgz#b0554f69ad89e22d0907f7a12b0993a5d96137ef"
integrity sha1-sFVPaa2J4i0JB/ehKwmTpdlhN+8=
dependencies:
big-integer "^1.6.16"
nanoid@^3.1.23, nanoid@^3.2.0:
version "3.2.0"
resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.2.0.tgz#62667522da6673971cca916a6d3eff3f415ff80c"
@ -4139,6 +4193,11 @@ object.values@^1.1.5:
define-properties "^1.1.3"
es-abstract "^1.19.1"
oblivious-set@1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/oblivious-set/-/oblivious-set-1.0.0.tgz#c8316f2c2fb6ff7b11b6158db3234c49f733c566"
integrity sha512-z+pI07qxo4c2CulUHCDf9lcqDlMSo72N/4rLUpRXf6fu+q8vjt8y0xS+Tlf8NTJDdTXHbdeO1n3MlbctwEoXZw==
on-finished@~2.3.0:
version "2.3.0"
resolved "https://registry.yarnpkg.com/on-finished/-/on-finished-2.3.0.tgz#20f1336481b083cd75337992a16971aa2d906947"
@ -4695,6 +4754,15 @@ react-motion@^0.5.2:
prop-types "^15.5.8"
raf "^3.1.0"
react-query@^3.39.0:
version "3.39.0"
resolved "https://registry.yarnpkg.com/react-query/-/react-query-3.39.0.tgz#0caca7b0da98e65008bbcd4df0d25618c2100050"
integrity sha512-Od0IkSuS79WJOhzWBx/ys0x13+7wFqgnn64vBqqAAnZ9whocVhl/y1padD5uuZ6EIkXbFbInax0qvY7zGM0thA==
dependencies:
"@babel/runtime" "^7.5.5"
broadcast-channel "^3.4.1"
match-sorter "^6.0.2"
react-with-forwarded-ref@^0.3.3:
version "0.3.4"
resolved "https://registry.yarnpkg.com/react-with-forwarded-ref/-/react-with-forwarded-ref-0.3.4.tgz#b1e884ea081ec3c5dd578f37889159797454c0a5"
@ -4767,6 +4835,11 @@ regexpp@^3.2.0:
resolved "https://registry.yarnpkg.com/regexpp/-/regexpp-3.2.0.tgz#0425a2768d8f23bad70ca4b90461fa2f1213e1b2"
integrity sha512-pq2bWo9mVD43nbts2wGv17XLiNLya+GklZ8kaDLV2Z08gDCsGpnKn9BFMepvWuHCbyVvY7J5o5+BVvoQbmlJLg==
remove-accents@0.4.2:
version "0.4.2"
resolved "https://registry.yarnpkg.com/remove-accents/-/remove-accents-0.4.2.tgz#0a43d3aaae1e80db919e07ae254b285d9e1c7bb5"
integrity sha1-CkPTqq4egNuRngeuJUsoXZ4ce7U=
require-directory@^2.1.1:
version "2.1.1"
resolved "https://registry.yarnpkg.com/require-directory/-/require-directory-2.1.1.tgz#8c64ad5fd30dab1c976e2344ffe7f792a6a6df42"
@ -4830,7 +4903,7 @@ rfdc@^1.3.0:
resolved "https://registry.yarnpkg.com/rfdc/-/rfdc-1.3.0.tgz#d0b7c441ab2720d05dc4cf26e01c89631d9da08b"
integrity sha512-V2hovdzFbOi77/WajaSMXk2OLm+xNIeQdMMuB7icj7bk6zi2F8GGAxigcnDFpJHbNyNcgyJDiP+8nOrY5cZGrA==
rimraf@^3.0.0, rimraf@^3.0.2:
rimraf@3.0.2, rimraf@^3.0.0, rimraf@^3.0.2:
version "3.0.2"
resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-3.0.2.tgz#f1a5402ba6220ad52cc1282bac1ae3aa49fd061a"
integrity sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==
@ -5421,6 +5494,14 @@ unique-string@^2.0.0:
dependencies:
crypto-random-string "^2.0.0"
unload@2.2.0:
version "2.2.0"
resolved "https://registry.yarnpkg.com/unload/-/unload-2.2.0.tgz#ccc88fdcad345faa06a92039ec0f80b488880ef7"
integrity sha512-B60uB5TNBLtN6/LsgAf3udH9saB5p7gqJwcFfbOEZ8BcBHnGwCf6G/TGiEqkRAxX7zAFIUtzdrXQSdL3Q/wqNA==
dependencies:
"@babel/runtime" "^7.6.2"
detect-node "^2.0.4"
unpipe@1.0.0, unpipe@~1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/unpipe/-/unpipe-1.0.0.tgz#b2bf4ee8514aae6165b4817829d21b2ef49904ec"