diff --git a/backend/api/src/get-site-activity.ts b/backend/api/src/get-site-activity.ts new file mode 100644 index 0000000000..0ae1aed883 --- /dev/null +++ b/backend/api/src/get-site-activity.ts @@ -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)) + } +} diff --git a/backend/api/src/routes.ts b/backend/api/src/routes.ts index 663421784a..833a51b21f 100644 --- a/backend/api/src/routes.ts +++ b/backend/api/src/routes.ts @@ -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 { createTask } from './create-task' import { updateTask } from './update-task' import { createCategory } from './create-category' @@ -146,6 +147,9 @@ import { getCategories } from './get-categories' import { updateCategory } from './update-category' import { getTasks } from './get-tasks' +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 } = { 'refresh-all-clients': refreshAllClients, @@ -309,4 +313,5 @@ export const handlers: { [k in APIPath]: APIHandler } = { 'get-categories': getCategories, 'update-category': updateCategory, 'get-tasks': getTasks, + 'get-site-activity': getSiteActivity, } diff --git a/common/src/api/schema.ts b/common/src/api/schema.ts index 1a29a4c65c..105e542781 100644 --- a/common/src/api/schema.ts +++ b/common/src/api/schema.ts @@ -1914,6 +1914,7 @@ export const API = (_apiTypeCheck = { userId: z.string(), }), }, + 'create-task': { method: 'POST', visibility: 'public', @@ -1986,6 +1987,24 @@ export const API = (_apiTypeCheck = { authed: true, returns: {} as { tasks: Task[] }, props: z.object({}).strict(), + + '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) diff --git a/web/components/contract/contract-mention.tsx b/web/components/contract/contract-mention.tsx index c394d43e76..51821f946d 100644 --- a/web/components/contract/contract-mention.tsx +++ b/web/components/contract/contract-mention.tsx @@ -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' @@ -23,19 +22,21 @@ 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'} > {contract.question} - - - + {contract.outcomeType === 'BINARY' && ( + + + + )} {!contract.resolution && probChange && ( {probChange} )} diff --git a/web/components/feed/feed-bets.tsx b/web/components/feed/feed-bets.tsx index bf7e047819..d6a83497ba 100644 --- a/web/components/feed/feed-bets.tsx +++ b/web/components/feed/feed-bets.tsx @@ -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') @@ -66,7 +66,6 @@ export const FeedBet = memo(function FeedBet(props: { className="flex-1" /> - ) diff --git a/web/components/site-activity.tsx b/web/components/site-activity.tsx new file mode 100644 index 0000000000..9ca8681b4b --- /dev/null +++ b/web/components/site-activity.tsx @@ -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 + + 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 ( + + + {groups.map(({ parentId, items }) => { + const contract = contractsById[parentId] as Contract + + return ( + + + + +
+ {items.map((item) => + 'amount' in item ? ( + + ) : 'question' in item ? ( + + ) : 'channelId' in item ? null : ( + + ) + )} +
+ + {contract.coverImageUrl && ( + + )} +
+ + ) + })} + + + ) +} + +const MarketCreatedLog = memo( + (props: { contract: Contract; showDescription?: boolean }) => { + const { + creatorId, + creatorAvatarUrl, + creatorUsername, + creatorName, + createdTime, + } = props.contract + const { showDescription = false } = props + + return ( + + + + + + + created + + + + + + {showDescription && props.contract.description && ( + // TODO: truncate if too long + + )} + + ) + } +) +// 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 ( + + + + + + + + + {' '} + commented + + + + + + ) +}) diff --git a/web/pages/activity.tsx b/web/pages/activity.tsx new file mode 100644 index 0000000000..bcd4b346ed --- /dev/null +++ b/web/pages/activity.tsx @@ -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 ( + + + + + + + Activity + + + + + + ) +}