diff --git a/common/recommended-contracts.ts b/common/recommended-contracts.ts new file mode 100644 index 00000000..fe849d4b --- /dev/null +++ b/common/recommended-contracts.ts @@ -0,0 +1,94 @@ +import _ from 'lodash' +import { Contract } from './contract' +import { filterDefined } from './util/array' +import { addObjects } from './util/object' + +export const getRecommendedContracts = ( + contractsById: { [contractId: string]: Contract }, + yourBetOnContractIds: string[] +) => { + const contracts = Object.values(contractsById) + const yourContracts = filterDefined( + yourBetOnContractIds.map((contractId) => contractsById[contractId]) + ) + + const yourContractIds = new Set(yourContracts.map((c) => c.id)) + const notYourContracts = contracts.filter((c) => !yourContractIds.has(c.id)) + + const yourWordFrequency = contractsToWordFrequency(yourContracts) + const otherWordFrequency = contractsToWordFrequency(notYourContracts) + const words = _.union( + Object.keys(yourWordFrequency), + Object.keys(otherWordFrequency) + ) + + const yourWeightedFrequency = _.fromPairs( + _.map(words, (word) => { + const [yourFreq, otherFreq] = [ + yourWordFrequency[word] ?? 0, + otherWordFrequency[word] ?? 0, + ] + + const score = yourFreq / (yourFreq + otherFreq + 0.0001) + + return [word, score] + }) + ) + + // console.log( + // 'your weighted frequency', + // _.sortBy(_.toPairs(yourWeightedFrequency), ([, freq]) => -freq) + // ) + + const scoredContracts = contracts.map((contract) => { + const wordFrequency = contractToWordFrequency(contract) + + const score = _.sumBy(Object.keys(wordFrequency), (word) => { + const wordFreq = wordFrequency[word] ?? 0 + const weight = yourWeightedFrequency[word] ?? 0 + return wordFreq * weight + }) + + return { + contract, + score, + } + }) + + return _.sortBy(scoredContracts, (scored) => -scored.score).map( + (scored) => scored.contract + ) +} + +const contractToText = (contract: Contract) => { + const { description, question, tags, creatorUsername } = contract + return `${creatorUsername} ${question} ${tags.join(' ')} ${description}` +} + +const getWordsCount = (text: string) => { + const normalizedText = text.replace(/[^a-zA-Z]/g, ' ').toLowerCase() + const words = normalizedText.split(' ').filter((word) => word) + + const counts: { [word: string]: number } = {} + for (const word of words) { + if (counts[word]) counts[word]++ + else counts[word] = 1 + } + return counts +} + +const toFrequency = (counts: { [word: string]: number }) => { + const total = _.sum(Object.values(counts)) + return _.mapValues(counts, (count) => count / total) +} + +const contractToWordFrequency = (contract: Contract) => + toFrequency(getWordsCount(contractToText(contract))) + +const contractsToWordFrequency = (contracts: Contract[]) => { + const frequencySum = contracts + .map(contractToWordFrequency) + .reduce(addObjects, {}) + + return toFrequency(frequencySum) +} diff --git a/common/util/math.ts b/common/util/math.ts new file mode 100644 index 00000000..f89e9d85 --- /dev/null +++ b/common/util/math.ts @@ -0,0 +1,6 @@ +export const logInterpolation = (min: number, max: number, value: number) => { + if (value <= min) return 0 + if (value >= max) return 1 + + return Math.log(value - min + 1) / Math.log(max - min + 1) +} diff --git a/web/components/feed/feed-items.tsx b/web/components/feed/feed-items.tsx index 86c4aaef..fbb9ea11 100644 --- a/web/components/feed/feed-items.tsx +++ b/web/components/feed/feed-items.tsx @@ -340,14 +340,6 @@ export function FeedQuestion(props: { > {question} - {!showDescription && ( - -
See more...
-
- )} {(isBinary || resolution) && ( diff --git a/web/components/feed/find-active-contracts.ts b/web/components/feed/find-active-contracts.ts index 7048f299..42737f47 100644 --- a/web/components/feed/find-active-contracts.ts +++ b/web/components/feed/find-active-contracts.ts @@ -59,7 +59,10 @@ export function findActiveContracts( } let activeContracts = allContracts.filter( - (contract) => contract.visibility === 'public' && !contract.isResolved + (contract) => + contract.visibility === 'public' && + !contract.isResolved && + (contract.closeTime ?? Infinity) > Date.now() ) activeContracts = _.sortBy( activeContracts, diff --git a/web/hooks/use-algo-feed.ts b/web/hooks/use-algo-feed.ts new file mode 100644 index 00000000..68958821 --- /dev/null +++ b/web/hooks/use-algo-feed.ts @@ -0,0 +1,172 @@ +import _ from 'lodash' +import { useState, useEffect, useMemo } from 'react' +import { Bet } from '../../common/bet' +import { Comment } from '../../common/comment' +import { Contract } from '../../common/contract' +import { User } from '../../common/user' +import { logInterpolation } from '../../common/util/math' +import { getRecommendedContracts } from '../../common/recommended-contracts' +import { useSeenContracts } from './use-seen-contracts' +import { useGetUserBetContractIds, useUserBetContracts } from './use-user-bets' + +const MAX_FEED_CONTRACTS = 75 + +export const useAlgoFeed = ( + user: User | null | undefined, + contracts: Contract[] | undefined, + recentBets: Bet[] | undefined, + recentComments: Comment[] | undefined +) => { + const initialContracts = useMemo(() => contracts, [!!contracts]) + const initialBets = useMemo(() => recentBets, [!!recentBets]) + const initialComments = useMemo(() => recentComments, [!!recentComments]) + + const yourBetContractIds = useGetUserBetContractIds(user?.id) + // Update user bet contracts in local storage. + useUserBetContracts(user?.id) + + const seenContracts = useSeenContracts() + + const [algoFeed, setAlgoFeed] = useState([]) + + useEffect(() => { + if (initialContracts && initialBets && initialComments) { + const eligibleContracts = initialContracts.filter( + (c) => !c.isResolved && (c.closeTime ?? Infinity) > Date.now() + ) + const contracts = getAlgoFeed( + eligibleContracts, + initialBets, + initialComments, + yourBetContractIds, + seenContracts + ) + setAlgoFeed(contracts) + } + }, [ + initialBets, + initialComments, + initialContracts, + seenContracts, + yourBetContractIds, + ]) + + return algoFeed +} + +const getAlgoFeed = ( + contracts: Contract[], + recentBets: Bet[], + recentComments: Comment[], + yourBetContractIds: string[], + seenContracts: { [contractId: string]: number } +) => { + const contractsById = _.keyBy(contracts, (c) => c.id) + + const recommended = getRecommendedContracts(contractsById, yourBetContractIds) + const confidence = logInterpolation(0, 100, yourBetContractIds.length) + const recommendedScores = _.fromPairs( + recommended.map((c, index) => { + const score = 1 - index / recommended.length + const withConfidence = score * confidence + (1 - confidence) + return [c.id, withConfidence] as [string, number] + }) + ) + + const seenScores = _.fromPairs( + contracts.map( + (c) => [c.id, getSeenContractsScore(c, seenContracts)] as [string, number] + ) + ) + + const activityScores = getContractsActivityScores( + contracts, + recentComments, + recentBets, + seenContracts + ) + + const combinedScores = contracts.map((contract) => { + const score = + (recommendedScores[contract.id] ?? 0) * + (seenScores[contract.id] ?? 0) * + (activityScores[contract.id] ?? 0) + return { contract, score } + }) + + const sorted = _.sortBy(combinedScores, (c) => -c.score) + return sorted.map((c) => c.contract).slice(0, MAX_FEED_CONTRACTS) +} + +function getContractsActivityScores( + contracts: Contract[], + recentComments: Comment[], + recentBets: Bet[], + seenContracts: { [contractId: string]: number } +) { + const contractBets = _.groupBy(recentBets, (bet) => bet.contractId) + const contractMostRecentBet = _.mapValues( + contractBets, + (bets) => _.maxBy(bets, (bet) => bet.createdTime) as Bet + ) + + const contractComments = _.groupBy( + recentComments, + (comment) => comment.contractId + ) + const contractMostRecentComment = _.mapValues( + contractComments, + (comments) => _.maxBy(comments, (c) => c.createdTime) as Comment + ) + + const scoredContracts = contracts.map((contract) => { + const seenTime = seenContracts[contract.id] + const lastCommentTime = contractMostRecentComment[contract.id]?.createdTime + const hasNewComments = + !seenTime || (lastCommentTime && lastCommentTime > seenTime) + const newCommentScore = hasNewComments ? 1 : 0.75 + + const commentCount = contractComments[contract.id]?.length ?? 0 + const betCount = contractBets[contract.id]?.length ?? 0 + const activtyCount = betCount + commentCount * 5 + const activityCountScore = + 0.5 + 0.5 * logInterpolation(0, 200, activtyCount) + + const lastBetTime = contractMostRecentBet[contract.id]?.createdTime + const timeSinceLastBet = !lastBetTime + ? contract.createdTime + : Date.now() - lastBetTime + const daysAgo = timeSinceLastBet / oneDayMs + const timeAgoScore = 1 - logInterpolation(0, 3, daysAgo) + + const score = newCommentScore * activityCountScore * timeAgoScore + + // Map score to [0.5, 1] since no recent activty is not a deal breaker. + const mappedScore = 0.5 + score / 2 + return [contract.id, mappedScore] as [string, number] + }) + + return _.fromPairs(scoredContracts) +} + +const oneDayMs = 24 * 60 * 60 * 1000 + +function getSeenContractsScore( + contract: Contract, + seenContracts: { [contractId: string]: number } +) { + const lastSeen = seenContracts[contract.id] + if (lastSeen === undefined) { + return 1 + } + + const daysAgo = (Date.now() - lastSeen) / oneDayMs + + if (daysAgo < 0.5) { + const frac = logInterpolation(0, 0.5, daysAgo) + return 0.5 * frac + } + + const frac = logInterpolation(0.5, 14, daysAgo) + return 0.5 + 0.5 * frac +} diff --git a/web/hooks/use-bets.ts b/web/hooks/use-bets.ts index 4c530f5b..5ea66e1c 100644 --- a/web/hooks/use-bets.ts +++ b/web/hooks/use-bets.ts @@ -2,7 +2,6 @@ import { useEffect, useState } from 'react' import { Contract } from '../../common/contract' import { Bet, - getRecentBets, listenForBets, listenForRecentBets, withoutAnteBets, @@ -37,11 +36,3 @@ export const useRecentBets = () => { useEffect(() => listenForRecentBets(setRecentBets), []) return recentBets } - -export const useGetRecentBets = () => { - const [recentBets, setRecentBets] = useState() - useEffect(() => { - getRecentBets().then(setRecentBets) - }, []) - return recentBets -} diff --git a/web/hooks/use-contracts.ts b/web/hooks/use-contracts.ts index af36cd82..26c43ed1 100644 --- a/web/hooks/use-contracts.ts +++ b/web/hooks/use-contracts.ts @@ -39,19 +39,6 @@ export const useInactiveContracts = () => { return contracts } -export const useUpdatedContracts = (initialContracts: Contract[]) => { - const [contracts, setContracts] = useState(initialContracts) - - useEffect(() => { - return listenForContracts((newContracts) => { - const contractMap = _.fromPairs(newContracts.map((c) => [c.id, c])) - setContracts(initialContracts.map((c) => contractMap[c.id])) - }) - }, [initialContracts]) - - return contracts -} - export const useTaggedContracts = (tags: string[] | undefined) => { const [contracts, setContracts] = useState( tags && tags.length === 0 ? [] : undefined diff --git a/web/hooks/use-find-active-contracts.ts b/web/hooks/use-find-active-contracts.ts deleted file mode 100644 index 66573728..00000000 --- a/web/hooks/use-find-active-contracts.ts +++ /dev/null @@ -1,150 +0,0 @@ -import _ from 'lodash' -import { useMemo, useRef } from 'react' - -import { Fold } from '../../common/fold' -import { User } from '../../common/user' -import { filterDefined } from '../../common/util/array' -import { findActiveContracts } from '../components/feed/find-active-contracts' -import { Bet } from '../lib/firebase/bets' -import { Comment, getRecentComments } from '../lib/firebase/comments' -import { Contract, getActiveContracts } from '../lib/firebase/contracts' -import { listAllFolds } from '../lib/firebase/folds' -import { useInactiveContracts } from './use-contracts' -import { useFollowedFoldIds } from './use-fold' -import { useSeenContracts } from './use-seen-contracts' -import { useUserBetContracts } from './use-user-bets' - -// used in static props -export const getAllContractInfo = async () => { - let [contracts, folds] = await Promise.all([ - getActiveContracts().catch((_) => []), - listAllFolds().catch(() => []), - ]) - - const recentComments = await getRecentComments() - - return { contracts, recentComments, folds } -} - -const defaultExcludedTags = [ - 'meta', - 'test', - 'trolling', - 'spam', - 'transaction', - 'personal', -] -const includedWithDefaultFeed = (contract: Contract) => { - const { lowercaseTags } = contract - - if (lowercaseTags.length === 0) return false - if (lowercaseTags.some((tag) => defaultExcludedTags.includes(tag))) - return false - return true -} - -export const useFilterYourContracts = ( - user: User | undefined | null, - folds: Fold[], - contracts: Contract[] -) => { - const followedFoldIds = useFollowedFoldIds(user) - - const followedFolds = filterDefined( - (followedFoldIds ?? []).map((id) => folds.find((fold) => fold.id === id)) - ) - - // Save the initial followed fold slugs. - const followedFoldSlugsRef = useRef() - if (followedFoldIds && !followedFoldSlugsRef.current) - followedFoldSlugsRef.current = followedFolds.map((f) => f.slug) - const initialFollowedFoldSlugs = followedFoldSlugsRef.current - - const tagSet = new Set( - _.flatten(followedFolds.map((fold) => fold.lowercaseTags)) - ) - - const yourBetContractIds = useUserBetContracts(user?.id) - const yourBetContracts = yourBetContractIds - ? new Set(yourBetContractIds) - : undefined - - // Show no contracts before your info is loaded. - let yourContracts: Contract[] = [] - if (yourBetContracts && followedFoldIds) { - // Show default contracts if no folds are followed. - if (followedFoldIds.length === 0) - yourContracts = contracts.filter( - (contract) => - includedWithDefaultFeed(contract) || yourBetContracts.has(contract.id) - ) - else - yourContracts = contracts.filter( - (contract) => - contract.lowercaseTags.some((tag) => tagSet.has(tag)) || - yourBetContracts.has(contract.id) - ) - } - - return { - yourContracts, - initialFollowedFoldSlugs, - } -} - -export const useFindActiveContracts = (props: { - contracts: Contract[] - recentBets: Bet[] - recentComments: Comment[] -}) => { - const { contracts, recentBets, recentComments } = props - - const seenContracts = useSeenContracts() - - const activeContracts = findActiveContracts( - contracts, - recentComments, - recentBets, - seenContracts - ) - - const betsByContract = _.groupBy(recentBets, (bet) => bet.contractId) - - const activeBets = activeContracts.map( - (contract) => betsByContract[contract.id] ?? [] - ) - - const commentsByContract = _.groupBy( - recentComments, - (comment) => comment.contractId - ) - - const activeComments = activeContracts.map( - (contract) => commentsByContract[contract.id] ?? [] - ) - - return { - activeContracts, - activeBets, - activeComments, - } -} - -export const useExploreContracts = (maxContracts = 75) => { - const inactiveContracts = useInactiveContracts() - - const contractsDict = _.fromPairs( - (inactiveContracts ?? []).map((c) => [c.id, c]) - ) - - // Preserve random ordering once inactiveContracts loaded. - const exploreContractIds = useMemo( - () => _.shuffle(Object.keys(contractsDict)), - // eslint-disable-next-line react-hooks/exhaustive-deps - [!!inactiveContracts] - ).slice(0, maxContracts) - - if (!inactiveContracts) return undefined - - return filterDefined(exploreContractIds.map((id) => contractsDict[id])) -} diff --git a/web/hooks/use-user-bets.ts b/web/hooks/use-user-bets.ts index 0f1ceecb..82606933 100644 --- a/web/hooks/use-user-bets.ts +++ b/web/hooks/use-user-bets.ts @@ -52,3 +52,17 @@ export const useUserBetContracts = (userId: string | undefined) => { return contractIds } + +export const useGetUserBetContractIds = (userId: string | undefined) => { + const [contractIds, setContractIds] = useState([]) + + useEffect(() => { + const key = `user-bet-contractIds-${userId}` + const userBetContractJson = localStorage.getItem(key) + if (userBetContractJson) { + setContractIds(JSON.parse(userBetContractJson)) + } + }, [userId]) + + return contractIds +} diff --git a/web/lib/firebase/contracts.ts b/web/lib/firebase/contracts.ts index 46474c3f..9d85991d 100644 --- a/web/lib/firebase/contracts.ts +++ b/web/lib/firebase/contracts.ts @@ -124,7 +124,7 @@ const activeContractsQuery = query( contractCollection, where('isResolved', '==', false), where('visibility', '==', 'public'), - where('volume24Hours', '>', 0) + where('volume7Days', '>', 0) ) export function getActiveContracts() { diff --git a/web/pages/home.tsx b/web/pages/home.tsx index 6d086fca..bc21f9b6 100644 --- a/web/pages/home.tsx +++ b/web/pages/home.tsx @@ -2,96 +2,56 @@ import React from 'react' import Router from 'next/router' import _ from 'lodash' -import { Contract } from '../lib/firebase/contracts' import { Page } from '../components/page' import { ActivityFeed } from '../components/feed/activity-feed' -import { Comment } from '../lib/firebase/comments' import FeedCreate from '../components/feed-create' import { Spacer } from '../components/layout/spacer' import { Col } from '../components/layout/col' import { useUser } from '../hooks/use-user' -import { Fold } from '../../common/fold' import { LoadingIndicator } from '../components/loading-indicator' -import { - getAllContractInfo, - useFilterYourContracts, - useFindActiveContracts, -} from '../hooks/use-find-active-contracts' -import { fromPropz, usePropz } from '../hooks/use-propz' -import { useGetRecentBets, useRecentBets } from '../hooks/use-bets' +import { useRecentBets } from '../hooks/use-bets' import { useActiveContracts } from '../hooks/use-contracts' import { useRecentComments } from '../hooks/use-comments' +import { useAlgoFeed } from '../hooks/use-algo-feed' -export const getStaticProps = fromPropz(getStaticPropz) -export async function getStaticPropz() { - const contractInfo = await getAllContractInfo() - - return { - props: contractInfo, - revalidate: 60, // regenerate after a minute - } -} - -const Home = (props: { - contracts: Contract[] - folds: Fold[] - recentComments: Comment[] -}) => { - props = usePropz(props, getStaticPropz) ?? { - contracts: [], - folds: [], - recentComments: [], - } - const { folds } = props +const Home = () => { const user = useUser() - const contracts = useActiveContracts() ?? props.contracts - const { yourContracts } = useFilterYourContracts(user, folds, contracts) + const contracts = useActiveContracts() + const contractsDict = _.keyBy(contracts, 'id') - const initialRecentBets = useGetRecentBets() - const recentBets = useRecentBets() ?? initialRecentBets - const recentComments = useRecentComments() ?? props.recentComments + const recentBets = useRecentBets() + const recentComments = useRecentComments() - const { activeContracts } = useFindActiveContracts({ - contracts: yourContracts, - recentBets: initialRecentBets ?? [], - recentComments: props.recentComments, - }) + const feedContracts = useAlgoFeed(user, contracts, recentBets, recentComments) + + const updatedContracts = feedContracts.map( + (contract) => contractsDict[contract.id] ?? contract + ) if (user === null) { Router.replace('/') return <> } - const activityContent = recentBets ? ( - - ) : ( - - ) + const activityContent = + contracts && recentBets && recentComments ? ( + + ) : ( + + ) return ( - - - {/* {initialFollowedFoldSlugs !== undefined && - initialFollowedFoldSlugs.length === 0 && - !IS_PRIVATE_MANIFOLD && ( - - )} */} - - - + {activityContent}