Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

New feed #3212

Merged
merged 14 commits into from
Dec 12, 2024
69 changes: 69 additions & 0 deletions backend/api/src/get-site-activity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { APIHandler } from 'api/helpers/endpoint'
import { createSupabaseDirectClient } from 'shared/supabase/init'
import { convertContract } from 'common/supabase/contracts'
import { filterDefined } from 'common/util/array'
import { uniqBy } from 'lodash'
import { log } from 'shared/utils'
import { convertBet } from 'common/supabase/bets'
import { convertContractComment } from 'common/supabase/comments'

export const getSiteActivity: APIHandler<'get-site-activity'> = async (props) => {
const { limit, blockedUserIds = [], blockedGroupSlugs = [], blockedContractIds = [] } = props
const pg = createSupabaseDirectClient()
log('getSiteActivity called', { limit })

const [recentBets, recentComments, newContracts] = await Promise.all([
// todo: show
// [ ] sweepcash bets >= 5
// [ ] large limit orders
// [ ] personalization based on followed users & topics
pg.manyOrNone(
`select * from contract_bets
where abs(amount) >= 500
and user_id != all($1)
and contract_id != all($2)
order by created_time desc limit $3`,
[blockedUserIds, blockedContractIds, limit * 5]
),
pg.manyOrNone(
`select * from contract_comments
where (likes - coalesce(dislikes, 0)) >= 2
and user_id != all($1)
and contract_id != all($2)
order by created_time desc limit $3`,
[blockedUserIds, blockedContractIds, limit]
),
pg.manyOrNone(
`select * from contracts
where visibility = 'public'
and tier != 'play'
and creator_id != all($1)
and id != all($2)
and not exists (
select 1 from group_contracts gc
join groups g on g.id = gc.group_id
where gc.contract_id = contracts.id
and g.slug = any($3)
)
order by created_time desc limit $4`,
[blockedUserIds, blockedContractIds, blockedGroupSlugs, limit]
),
])

const contractIds = uniqBy([
...recentBets.map((b) => b.contract_id),
...recentComments.map((c) => c.contract_id)
], id => id)

const relatedContracts = await pg.manyOrNone(
`select * from contracts where id = any($1)`,
[contractIds]
)

return {
bets: recentBets.map(convertBet),
comments: recentComments.map(convertContractComment),
newContracts: filterDefined(newContracts.map(convertContract)),
relatedContracts: filterDefined(relatedContracts.map(convertContract))
}
}
2 changes: 2 additions & 0 deletions backend/api/src/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,7 @@ import { generateAIAnswers } from './generate-ai-answers'
import { getmonthlybets2024 } from './get-monthly-bets-2024'
import { getmaxminprofit2024 } from './get-max-min-profit-2024'
import { getNextLoanAmount } from './get-next-loan-amount'
import { getSiteActivity } from './get-site-activity'

// we define the handlers in this object in order to typecheck that every API has a handler
export const handlers: { [k in APIPath]: APIHandler<k> } = {
Expand Down Expand Up @@ -297,4 +298,5 @@ export const handlers: { [k in APIPath]: APIHandler<k> } = {
'get-monthly-bets-2024': getmonthlybets2024,
'get-max-min-profit-2024': getmaxminprofit2024,
'get-next-loan-amount': getNextLoanAmount,
'get-site-activity': getSiteActivity,
}
17 changes: 17 additions & 0 deletions common/src/api/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1913,6 +1913,23 @@ export const API = (_apiTypeCheck = {
userId: z.string(),
}),
},
'get-site-activity': {
method: 'GET',
visibility: 'public',
authed: false,
returns: {} as {
bets: Bet[]
comments: ContractComment[]
newContracts: Contract[]
relatedContracts: Contract[]
},
props: z.object({
limit: z.coerce.number().default(10),
blockedUserIds: z.array(z.string()).optional(),
blockedGroupSlugs: z.array(z.string()).optional(),
blockedContractIds: z.array(z.string()).optional(),
}).strict(),
},
} as const)

export type APIPath = keyof typeof API
Expand Down
23 changes: 13 additions & 10 deletions web/components/contract/contract-mention.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import { TRADED_TERM } from 'common/envs/constants'
import { formatWithToken } from 'common/util/format'
import Link from 'next/link'
import { useIsClient } from 'web/hooks/use-is-client'
import { getIsNative } from 'web/lib/native/is-native'
import { fromNow } from 'web/lib/util/time'
import { ContractStatusLabel } from './contracts-table'
import { getTextColor } from './text-color'
Expand All @@ -23,19 +22,23 @@ export function ContractMention(props: {
href={contractPath(contract)}
className={clsx('group inline whitespace-nowrap rounded-sm', className)}
title={isClient ? tooltipLabel(contract) : undefined}
target={getIsNative() ? '_self' : '_blank'}
// target={getIsNative() ? '_self' : '_blank'}
>
<span className="break-anywhere text-ink-900 group-hover:text-primary-500 group-focus:text-primary-500 mr-0.5 whitespace-normal font-medium transition-colors">
{contract.question}
</span>
<span
className={clsx(
probTextColor,
'ring-primary-100 group-hover:ring-primary-200 inline-flex rounded-full px-2 align-bottom font-semibold ring-1 ring-inset transition-colors'
)}
>
<ContractStatusLabel contract={contract} />
</span>

{contract.outcomeType === 'BINARY' && (
<span
className={clsx(
probTextColor,
'ring-primary-100 group-hover:ring-primary-200 inline-flex rounded-full px-2 align-bottom font-semibold ring-1 ring-inset transition-colors'
)}
>
<ContractStatusLabel contract={contract} />
</span>
)}

{!contract.resolution && probChange && (
<span className="text-ink-500 ml-0.5 text-xs">{probChange}</span>
)}
Expand Down
3 changes: 1 addition & 2 deletions web/components/feed/feed-bets.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ export const FeedBet = memo(function FeedBet(props: {
className?: string
onReply?: (bet: Bet) => void
}) {
const { contract, bet, avatarSize, className, onReply } = props
const { contract, bet, avatarSize, className } = props
const { createdTime, userId } = bet
const user = useDisplayUserById(userId)
const showUser = dayjs(createdTime).isAfter('2022-06-01')
Expand All @@ -66,7 +66,6 @@ export const FeedBet = memo(function FeedBet(props: {
className="flex-1"
/>
</Row>
<BetActions onReply={onReply} bet={bet} contract={contract} />
</Row>
</Col>
)
Expand Down
199 changes: 199 additions & 0 deletions web/components/site-activity.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
import clsx from 'clsx'
import { ContractComment } from 'common/comment'
import { Contract } from 'common/contract'
import { groupBy, keyBy, orderBy } from 'lodash'
import { memo } from 'react'
import { usePrivateUser } from 'web/hooks/use-user'
import { ContractMention } from './contract/contract-mention'
import { FeedBet } from './feed/feed-bets'
import { Col } from './layout/col'
import { Row } from './layout/row'
import { RelativeTimestamp } from './relative-timestamp'
import { Avatar } from './widgets/avatar'
import { Content } from './widgets/editor'
import { LoadingIndicator } from './widgets/loading-indicator'
import { UserLink } from './widgets/user-link'
import { UserHovercard } from './user/user-hovercard'
import { useAPIGetter } from 'web/hooks/use-api-getter'

export function SiteActivity(props: {
className?: string
blockedUserIds?: string[]
}) {
const { className } = props
const privateUser = usePrivateUser()

const blockedGroupSlugs = privateUser?.blockedGroupSlugs ?? []
const blockedContractIds = privateUser?.blockedContractIds ?? []
const blockedUserIds = (privateUser?.blockedUserIds ?? []).concat(
props.blockedUserIds ?? []
)

const { data, loading } = useAPIGetter('get-site-activity', {
limit: 10,
blockedUserIds,
blockedGroupSlugs,
blockedContractIds,
})

if (loading || !data) return <LoadingIndicator />

const { bets, comments, newContracts, relatedContracts } = data
const contracts = [...newContracts, ...relatedContracts]
const contractsById = keyBy(contracts, 'id')

const items = orderBy(
[...bets, ...comments, ...newContracts],
'createdTime',
'desc'
)

const groups = orderBy(
Object.entries(
groupBy(items, (item) =>
'contractId' in item ? item.contractId : item.id
)
).map(([parentId, items]) => ({
parentId,
items,
})),
({ items }) => Math.max(...items.map((item) => item.createdTime)),
'desc'
)

return (
<Col className={clsx('gap-4', className)}>
<Col className="gap-4">
{groups.map(({ parentId, items }) => {
const contract = contractsById[parentId] as Contract

return (
<Col
key={parentId}
className="bg-canvas-0 border-canvas-50 hover:border-primary-300 gap-2 rounded-lg border px-4 py-3 transition-colors"
>
<Row className="gap-2">
<Col className="flex-1 gap-2">
<ContractMention contract={contract} />
<div className="space-y-2">
{items.map((item) =>
'amount' in item ? (
<FeedBet
className="!pt-0"
key={item.id}
contract={contract}
bet={item}
avatarSize="xs"
/>
) : 'question' in item ? (
<MarketCreatedLog
key={item.id}
contract={item}
showDescription={items.length === 1}
/>
) : 'channelId' in item ? null : (
<CommentLog key={item.id} comment={item} />
)
)}
</div>
</Col>
{contract.coverImageUrl && (
<img
src={contract.coverImageUrl}
alt=""
className="h-32 w-32 rounded-md object-cover"
/>
)}
</Row>
</Col>
)
})}
</Col>
</Col>
)
}

const MarketCreatedLog = memo(
(props: { contract: Contract; showDescription?: boolean }) => {
const {
creatorId,
creatorAvatarUrl,
creatorUsername,
creatorName,
createdTime,
} = props.contract
const { showDescription = false } = props

return (
<Col className="gap-2">
<UserHovercard userId={creatorId} className="flex-col">
<Row className="text-ink-600 items-center gap-2 text-sm">
<Avatar
avatarUrl={creatorAvatarUrl}
username={creatorUsername}
size="xs"
/>
<UserLink
user={{
id: creatorId,
name: creatorName,
username: creatorUsername,
}}
/>
<Row className="text-ink-400">
created
<RelativeTimestamp time={createdTime} />
</Row>
</Row>
</UserHovercard>

{showDescription && props.contract.description && (
// TODO: truncate if too long
<Content
size="sm"
content={props.contract.description}
className="mt-2 text-left"
/>
)}
</Col>
)
}
)
// todo: add liking/disliking
const CommentLog = memo(function FeedComment(props: {
comment: ContractComment
}) {
const { comment } = props
const {
userName,
text,
content,
userId,
userUsername,
userAvatarUrl,
createdTime,
} = comment

return (
<Col>
<Row
id={comment.id}
className="text-ink-500 mb-1 items-center gap-2 text-sm"
>
<UserHovercard userId={userId}>
<Avatar size="xs" username={userUsername} avatarUrl={userAvatarUrl} />
</UserHovercard>
<span>
<UserHovercard userId={userId}>
<UserLink
user={{ id: userId, name: userName, username: userUsername }}
/>
</UserHovercard>{' '}
commented
</span>
<RelativeTimestamp time={createdTime} />
</Row>
<Content size="sm" className="grow" content={content || text} />
</Col>
)
})
31 changes: 31 additions & 0 deletions web/pages/activity.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { Col } from 'web/components/layout/col'
import { Page } from 'web/components/layout/page'
import { SEO } from 'web/components/SEO'
import { Row } from 'web/components/layout/row'
import { SiteActivity } from 'web/components/site-activity'
import { TRADE_TERM } from 'common/envs/constants'

export default function ActivityPage() {
return (
<Page trackPageView={'activity page'}>
<SEO
title="Activity"
description={`Watch all site activity live, including ${TRADE_TERM}s, comments, and new questions.`}
url="/activity"
/>

<Col className="w-full max-w-3xl gap-4 self-center sm:pb-4">
<Row
className={
'w-full items-center justify-between pt-1 sm:justify-start sm:gap-4'
}
>
<span className="text-primary-700 line-clamp-1 shrink px-1 text-2xl">
Activity
</span>
</Row>
<SiteActivity className="w-full" />
</Col>
</Page>
)
}
Loading