* Folds=>groups

* Show groups on user profile

* Allow group creation from /create

* Refactoring to groups

* Convert folds to groups

* Add new add to group notification

* Fix user profile tab bug

* Add groups nav and tab for my groups

* Remove bad profile pages

* remove comments

* Add group list dropdown to sidebar

* remove unused

* group cards ui

* Messages=>Comments, v2, groupDetails

* Discussion time

* Cleaning up some code

* Remove follow count

* Fix pool scoring for cpmm

* Fix imports

* Simplify rules, add GroupUser collection

* Fix group cards

* Refactor

* Refactor

* Small fixes

* Remove string

* Add api error detail handling

* Clear name field

* Componentize

* Spacing

* Undo userpage memo

* Member groups are already in my tab

* Remove active contracts reference for now

* Remove unused

* Refactoring

* Allow adding old questions to a group

* Rename

* Wording

* Throw standard v2 APIError

* Hide input for non-members, add about under title

* Multiple names to & # more

* Move comments firestore rules to appropriate subpaths

* Group membership, pool=>volume

* Cleanup, useEvent

* Raise state to parent

* Eliminate unused

* Cleaning up

* Clean code

* Revert tags input deletion

* Cleaning code

* Stylling

* Limit members to display

* Array cleanup

* Add categories back in

* Private=>closed

* Unused vars
This commit is contained in:
Ian Philips 2022-06-22 11:35:50 -05:00 committed by GitHub
parent 67d0a6c0c2
commit 3b3717d307
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
68 changed files with 2319 additions and 1625 deletions

View File

@ -2,7 +2,8 @@
// They're uniquely identified by the pair contractId/betId.
export type Comment = {
id: string
contractId: string
contractId?: string
groupId?: string
betId?: string
answerOutcome?: string
replyToCommentId?: string

View File

@ -1,5 +1,6 @@
import { Answer } from './answer'
import { Fees } from './fees'
import { GroupDetails } from 'common/group'
export type AnyMechanism = DPM | CPMM
export type AnyOutcomeType = Binary | FreeResponse | Numeric
@ -24,6 +25,8 @@ export type Contract<T extends AnyContractType = AnyContractType> = {
lowercaseTags: string[]
visibility: 'public' | 'unlisted'
groupDetails?: GroupDetails[] // Starting with one group per contract
createdTime: number // Milliseconds since epoch
lastUpdatedTime?: number // Updated on new bet or comment
lastBetTime?: number

View File

@ -17,6 +17,7 @@ export const DEV_CONFIG: EnvConfig = {
sellshares: 'https://sellshares-w3txbmd3ba-uc.a.run.app',
sellbet: 'https://sellbet-w3txbmd3ba-uc.a.run.app',
createmarket: 'https://createmarket-w3txbmd3ba-uc.a.run.app',
creategroup: 'https://creategroup-w3txbmd3ba-uc.a.run.app',
},
amplitudeApiKey: 'fd8cbfd964b9a205b8678a39faae71b3',
}

View File

@ -3,6 +3,7 @@ export type V2CloudFunction =
| 'sellbet'
| 'sellshares'
| 'createmarket'
| 'creategroup'
export type EnvConfig = {
domain: string
@ -52,6 +53,7 @@ export const PROD_CONFIG: EnvConfig = {
sellshares: 'https://sellshares-nggbo3neva-uc.a.run.app',
sellbet: 'https://sellbet-nggbo3neva-uc.a.run.app',
createmarket: 'https://createmarket-nggbo3neva-uc.a.run.app',
creategroup: 'https://creategroup-nggbo3neva-uc.a.run.app',
},
adminEmails: [
'akrolsmir@gmail.com', // Austin

View File

@ -18,6 +18,7 @@ export const THEOREMONE_CONFIG: EnvConfig = {
sellshares: 'https://sellshares-nggbo3neva-uc.a.run.app',
sellbet: 'https://sellbet-nggbo3neva-uc.a.run.app',
createmarket: 'https://createmarket-nggbo3neva-uc.a.run.app',
creategroup: 'https://creategroup-nggbo3neva-uc.a.run.app',
},
adminEmails: [...PROD_CONFIG.adminEmails, 'david.glidden@theoremone.co'],
whitelistEmail: '@theoremone.co',

View File

@ -1,23 +0,0 @@
export type Fold = {
id: string
slug: string
name: string
about: string
curatorId: string // User id
createdTime: number
tags: string[]
lowercaseTags: string[]
contractIds: string[]
excludedContractIds: string[]
// Invariant: exactly one of the following is defined.
// Default: creatorIds: undefined, excludedCreatorIds: []
creatorIds?: string[]
excludedCreatorIds?: string[]
followCount: number
disallowMarketCreation?: boolean
}

21
common/group.ts Normal file
View File

@ -0,0 +1,21 @@
export type Group = {
id: string
slug: string
name: string
about: string
creatorId: string // User id
createdTime: number
mostRecentActivityTime: number
memberIds: string[] // User ids
anyoneCanJoin: boolean
contractIds: string[]
}
export const MAX_GROUP_NAME_LENGTH = 75
export const MAX_ABOUT_LENGTH = 140
export const MAX_ID_LENGTH = 60
export type GroupDetails = {
groupId: string
groupSlug: string
groupName: string
}

View File

@ -11,6 +11,7 @@ import {
import { User } from './user'
import { parseTags } from './util/parse'
import { removeUndefinedProps } from './util/object'
import { GroupDetails } from 'common/group'
export function getNewContract(
id: string,
@ -27,7 +28,8 @@ export function getNewContract(
// used for numeric markets
bucketCount: number,
min: number,
max: number
max: number,
groupDetails?: GroupDetails
) {
const tags = parseTags(
`${question} ${description} ${extraTags.map((tag) => `#${tag}`).join(' ')}`
@ -69,6 +71,7 @@ export function getNewContract(
liquidityFee: 0,
platformFee: 0,
},
groupDetails: groupDetails ? [groupDetails] : undefined,
})
return contract as Contract

View File

@ -15,10 +15,13 @@ export type Notification = {
sourceUserUsername?: string
sourceUserAvatarUrl?: string
sourceText?: string
sourceContractTitle?: string
sourceContractCreatorUsername?: string
sourceContractSlug?: string
sourceContractTags?: string[]
sourceSlug?: string
sourceTitle?: string
}
export type notification_source_types =
| 'contract'
@ -29,6 +32,7 @@ export type notification_source_types =
| 'follow'
| 'tip'
| 'admin_message'
| 'group'
export type notification_source_update_types =
| 'created'
@ -48,3 +52,4 @@ export type notification_reason_types =
| 'reply_to_users_comment'
| 'on_new_follow'
| 'you_follow_user'
| 'added_you_to_group'

View File

@ -7,7 +7,12 @@ import { getPayouts } from './payouts'
export function scoreCreators(contracts: Contract[]) {
const creatorScore = mapValues(
groupBy(contracts, ({ creatorId }) => creatorId),
(contracts) => sumBy(contracts, ({ pool }) => pool.YES + pool.NO)
(contracts) =>
sumBy(
contracts.map((contract) => {
return contract.volume
})
)
)
return creatorScore

View File

@ -59,6 +59,9 @@ service cloud.firestore {
.hasOnly(['description', 'closeTime'])
&& resource.data.creatorId == request.auth.uid;
allow update: if isAdmin();
match /comments/{commentId} {
allow create: if request.auth != null && commentMatchesUser(request.auth.uid, request.resource.data);
}
}
match /{somePath=**}/bets/{betId} {
@ -80,20 +83,12 @@ service cloud.firestore {
match /{somePath=**}/comments/{commentId} {
allow read;
allow create: if request.auth != null && commentMatchesUser(request.auth.uid, request.resource.data);
}
match /{somePath=**}/answers/{answerId} {
allow read;
}
match /folds/{foldId} {
allow read;
allow update: if request.auth.uid == resource.data.curatorId
&& request.resource.data.diff(resource.data).affectedKeys()
.hasOnly(['name', 'about', 'tags', 'lowercaseTags']);
allow delete: if request.auth.uid == resource.data.curatorId;
}
match /{somePath=**}/followers/{userId} {
allow read;
@ -111,5 +106,21 @@ service cloud.firestore {
&& request.resource.data.diff(resource.data).affectedKeys()
.hasOnly(['isSeen', 'viewTime']);
}
match /groups/{groupId} {
allow read;
allow update: if request.auth.uid in resource.data.memberIds
&& request.resource.data.diff(resource.data).affectedKeys()
.hasOnly(['name', 'about', 'contractIds', 'memberIds', 'anyoneCanJoin' ]);
allow delete: if request.auth.uid == resource.data.creatorId;
function isMember() {
return request.auth.uid in get(/databases/$(database)/documents/groups/$(groupId)).data.memberIds;
}
match /comments/{commentId} {
allow create: if request.auth != null && commentMatchesUser(request.auth.uid, request.resource.data) && isMember();
}
}
}
}

View File

@ -96,6 +96,7 @@ export const validate = <T extends z.ZodTypeAny>(schema: T, val: unknown) => {
const result = schema.safeParse(val)
if (!result.success) {
const issues = result.error.issues.map((i) => {
// TODO: export this type for the front-end to parse
return {
field: i.path.join('.') || null,
error: i.message,

View File

@ -41,7 +41,7 @@ export const backupDb = functions.pubsub
// NOTE: Subcollections are not backed up by default
collectionIds: [
'contracts',
'folds',
'groups',
'private-users',
'stripe-transactions',
'users',

View File

@ -28,6 +28,7 @@ import { getNoneAnswer } from '../../common/answer'
import { getNewContract } from '../../common/new-contract'
import { NUMERIC_BUCKET_COUNT } from '../../common/numeric-constants'
import { User } from '../../common/user'
import { Group, MAX_ID_LENGTH } from '../../common/group'
const bodySchema = z.object({
question: z.string().min(1).max(MAX_QUESTION_LENGTH),
@ -38,6 +39,7 @@ const bodySchema = z.object({
'Close time must be in the future.'
),
outcomeType: z.enum(OUTCOME_TYPES),
groupId: z.string().min(1).max(MAX_ID_LENGTH).optional(),
})
const binarySchema = z.object({
@ -50,10 +52,8 @@ const numericSchema = z.object({
})
export const createmarket = newEndpoint(['POST'], async (req, auth) => {
const { question, description, tags, closeTime, outcomeType } = validate(
bodySchema,
req.body
)
const { question, description, tags, closeTime, outcomeType, groupId } =
validate(bodySchema, req.body)
let min, max, initialProb
if (outcomeType === 'NUMERIC') {
@ -77,6 +77,19 @@ export const createmarket = newEndpoint(['POST'], async (req, auth) => {
}
const user = userDoc.data() as User
let group = null
if (groupId) {
const groupDoc = await firestore.collection('groups').doc(groupId).get()
if (!groupDoc.exists) {
throw new APIError(400, 'No group exists with the given group ID.')
}
group = groupDoc.data() as Group
if (!group.memberIds.includes(user.id)) {
throw new APIError(400, 'User is not a member of the group.')
}
}
const userContractsCreatedTodaySnapshot = await firestore
.collection(`contracts`)
.where('creatorId', '==', auth.uid)
@ -115,7 +128,14 @@ export const createmarket = newEndpoint(['POST'], async (req, auth) => {
tags ?? [],
NUMERIC_BUCKET_COUNT,
min ?? 0,
max ?? 0
max ?? 0,
group
? {
groupId: group.id,
groupName: group.name,
groupSlug: group.slug,
}
: undefined
)
if (!isFree && ante) await chargeUser(user.id, ante, true)

View File

@ -1,95 +0,0 @@
import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin'
import { getUser } from './utils'
import { Contract } from '../../common/contract'
import { slugify } from '../../common/util/slugify'
import { randomString } from '../../common/util/random'
import { Fold } from '../../common/fold'
export const createFold = functions.runWith({ minInstances: 1 }).https.onCall(
async (
data: {
name: string
about: string
tags: string[]
},
context
) => {
const userId = context?.auth?.uid
if (!userId) return { status: 'error', message: 'Not authorized' }
const creator = await getUser(userId)
if (!creator) return { status: 'error', message: 'User not found' }
let { name, about } = data
if (!name || typeof name !== 'string')
return { status: 'error', message: 'Name must be a non-empty string' }
name = name.trim().slice(0, 140)
if (typeof about !== 'string')
return { status: 'error', message: 'About must be a string' }
about = about.trim().slice(0, 140)
const { tags } = data
if (!Array.isArray(tags))
return { status: 'error', message: 'Tags must be an array of strings' }
console.log(
'creating fold for',
creator.username,
'named',
name,
'about',
about,
'tags',
tags
)
const slug = await getSlug(name)
const foldRef = firestore.collection('folds').doc()
const fold: Fold = {
id: foldRef.id,
curatorId: userId,
slug,
name,
about,
tags,
lowercaseTags: tags.map((tag) => tag.toLowerCase()),
createdTime: Date.now(),
contractIds: [],
excludedContractIds: [],
excludedCreatorIds: [],
followCount: 0,
}
await foldRef.create(fold)
await foldRef.collection('followers').doc(userId).set({ userId })
return { status: 'success', fold }
}
)
const getSlug = async (name: string) => {
const proposedSlug = slugify(name)
const preexistingFold = await getFoldFromSlug(proposedSlug)
return preexistingFold ? proposedSlug + '-' + randomString() : proposedSlug
}
const firestore = admin.firestore()
export async function getFoldFromSlug(slug: string) {
const snap = await firestore
.collection('folds')
.where('slug', '==', slug)
.get()
return snap.empty ? undefined : (snap.docs[0].data() as Contract)
}

View File

@ -0,0 +1,87 @@
import * as admin from 'firebase-admin'
import { getUser } from './utils'
import { Contract } from '../../common/contract'
import { slugify } from '../../common/util/slugify'
import { randomString } from '../../common/util/random'
import {
Group,
MAX_ABOUT_LENGTH,
MAX_GROUP_NAME_LENGTH,
MAX_ID_LENGTH,
} from '../../common/group'
import { APIError, newEndpoint, validate } from '../../functions/src/api'
import { z } from 'zod'
const bodySchema = z.object({
name: z.string().min(1).max(MAX_GROUP_NAME_LENGTH),
memberIds: z.array(z.string().min(1).max(MAX_ID_LENGTH)),
anyoneCanJoin: z.boolean(),
about: z.string().min(1).max(MAX_ABOUT_LENGTH).optional(),
})
export const creategroup = newEndpoint(['POST'], async (req, auth) => {
const { name, about, memberIds, anyoneCanJoin } = validate(
bodySchema,
req.body
)
const creator = await getUser(auth.uid)
if (!creator)
throw new APIError(400, 'No user exists with the authenticated user ID.')
// Add creator id to member ids for convenience
if (!memberIds.includes(creator.id)) memberIds.push(creator.id)
console.log(
'creating group for',
creator.username,
'named',
name,
'about',
about,
'other member ids',
memberIds
)
const slug = await getSlug(name)
const groupRef = firestore.collection('groups').doc()
const group: Group = {
id: groupRef.id,
creatorId: creator.id,
slug,
name,
about: about ?? '',
createdTime: Date.now(),
mostRecentActivityTime: Date.now(),
// TODO: allow users to add contract ids on group creation
contractIds: [],
anyoneCanJoin,
memberIds,
}
await groupRef.create(group)
return { status: 'success', group: group }
})
const getSlug = async (name: string) => {
const proposedSlug = slugify(name)
const preexistingGroup = await getGroupFromSlug(proposedSlug)
return preexistingGroup ? proposedSlug + '-' + randomString() : proposedSlug
}
const firestore = admin.firestore()
export async function getGroupFromSlug(slug: string) {
const snap = await firestore
.collection('groups')
.where('slug', '==', slug)
.get()
return snap.empty ? undefined : (snap.docs[0].data() as Contract)
}

View File

@ -29,7 +29,9 @@ export const createNotification = async (
sourceText: string,
sourceContract?: Contract,
relatedSourceType?: notification_source_types,
relatedUserId?: string
relatedUserId?: string,
sourceSlug?: string,
sourceTitle?: string
) => {
const shouldGetNotification = (
userId: string,
@ -63,10 +65,12 @@ export const createNotification = async (
sourceUserUsername: sourceUser.username,
sourceUserAvatarUrl: sourceUser.avatarUrl,
sourceText,
sourceContractTitle: sourceContract?.question,
sourceContractCreatorUsername: sourceContract?.creatorUsername,
// TODO: move away from sourceContractTitle to sourceTitle
sourceContractTitle: sourceContract?.question,
sourceContractSlug: sourceContract?.slug,
sourceContractTags: sourceContract?.tags,
sourceSlug: sourceSlug ? sourceSlug : sourceContract?.slug,
sourceTitle: sourceTitle ? sourceTitle : sourceContract?.question,
}
await notificationRef.set(removeUndefinedProps(notification))
})
@ -238,6 +242,16 @@ export const createNotification = async (
})
}
const notifyUserAddedToGroup = async (
userToReasonTexts: user_to_reason_texts,
relatedUserId: string
) => {
if (shouldGetNotification(relatedUserId, userToReasonTexts))
userToReasonTexts[relatedUserId] = {
reason: 'added_you_to_group',
}
}
const getUsersToNotify = async () => {
const userToReasonTexts: user_to_reason_texts = {}
// The following functions modify the userToReasonTexts object in place.
@ -273,6 +287,9 @@ export const createNotification = async (
}
} else if (sourceType === 'follow' && relatedUserId) {
await notifyFollowedUser(userToReasonTexts, relatedUserId)
} else if (sourceType === 'group' && relatedUserId) {
if (sourceUpdateType === 'created')
await notifyUserAddedToGroup(userToReasonTexts, relatedUserId)
}
return userToReasonTexts
}

View File

@ -8,12 +8,9 @@ export * from './transact'
export * from './resolve-market'
export * from './stripe'
export * from './create-user'
export * from './create-fold'
export * from './create-answer'
export * from './on-create-bet'
export * from './on-create-comment'
export * from './on-fold-follow'
export * from './on-fold-delete'
export * from './on-view'
export * from './unsubscribe'
export * from './update-metrics'
@ -27,6 +24,8 @@ export * from './on-create-contract'
export * from './on-follow-user'
export * from './on-unfollow-user'
export * from './on-create-liquidity-provision'
export * from './on-update-group'
export * from './on-create-group'
// v2
export * from './health'
@ -35,3 +34,4 @@ export * from './sell-bet'
export * from './sell-shares'
export * from './create-contract'
export * from './withdraw-liquidity'
export * from './create-group'

View File

@ -0,0 +1,30 @@
import * as functions from 'firebase-functions'
import { getUser } from './utils'
import { createNotification } from './create-notification'
import { Group } from '../../common/group'
export const onCreateGroup = functions.firestore
.document('groups/{groupId}')
.onCreate(async (change, context) => {
const group = change.data() as Group
const { eventId } = context
const groupCreator = await getUser(group.creatorId)
if (!groupCreator) throw new Error('Could not find group creator')
// create notifications for all members of the group
for (const memberId of group.memberIds) {
await createNotification(
group.id,
'group',
'created',
groupCreator,
eventId,
group.about,
undefined,
undefined,
memberId,
group.slug,
group.name
)
}
})

View File

@ -1,10 +0,0 @@
import * as functions from 'firebase-functions'
export const onFoldDelete = functions.firestore
.document('folds/{foldId}')
.onDelete(async (change, _context) => {
const snapshot = await change.ref.collection('followers').get()
// Delete followers sub-collection.
await Promise.all(snapshot.docs.map((doc) => doc.ref.delete()))
})

View File

@ -1,17 +0,0 @@
import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin'
const firestore = admin.firestore()
export const onFoldFollow = functions.firestore
.document('folds/{foldId}/followers/{userId}')
.onWrite(async (change, context) => {
const { foldId } = context.params
const snapshot = await firestore
.collection(`folds/${foldId}/followers`)
.get()
const followCount = snapshot.size
await firestore.doc(`folds/${foldId}`).update({ followCount })
})

View File

@ -0,0 +1,20 @@
import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin'
import { Group } from '../../common/group'
const firestore = admin.firestore()
export const onUpdateGroup = functions.firestore
.document('groups/{groupId}')
.onUpdate(async (change) => {
const prevGroup = change.before.data() as Group
const group = change.after.data() as Group
// ignore the update we just made
if (prevGroup.mostRecentActivityTime !== group.mostRecentActivityTime)
return
await firestore
.collection('groups')
.doc(group.id)
.update({ mostRecentActivityTime: Date.now() })
})

View File

@ -1,34 +0,0 @@
import * as admin from 'firebase-admin'
import { uniq } from 'lodash'
import { initAdmin } from './script-init'
initAdmin()
import { getValues } from '../utils'
import { Fold } from '../../../common/fold'
async function lowercaseFoldTags() {
const firestore = admin.firestore()
console.log('Updating fold tags')
const folds = await getValues<Fold>(firestore.collection('folds'))
console.log('Loaded', folds.length, 'folds')
for (const fold of folds) {
const foldRef = firestore.doc(`folds/${fold.id}`)
const { tags } = fold
const lowercaseTags = uniq(tags.map((tag) => tag.toLowerCase()))
console.log('Adding lowercase tags', fold.slug, lowercaseTags)
await foldRef.update({
lowercaseTags,
} as Partial<Fold>)
}
}
if (require.main === module) {
lowercaseFoldTags().then(() => process.exit())
}

View File

@ -18,40 +18,63 @@ export function ConfirmationButton(props: {
label?: string
className?: string
}
onSubmit: () => void
children: ReactNode
onSubmit?: () => void
onOpenChanged?: (isOpen: boolean) => void
onSubmitWithSuccess?: () => Promise<boolean>
}) {
const { openModalBtn, cancelBtn, submitBtn, onSubmit, children } = props
const {
openModalBtn,
cancelBtn,
submitBtn,
onSubmit,
children,
onOpenChanged,
onSubmitWithSuccess,
} = props
const [open, setOpen] = useState(false)
function updateOpen(newOpen: boolean) {
onOpenChanged?.(newOpen)
setOpen(newOpen)
}
return (
<>
<Modal open={open} setOpen={setOpen}>
<Modal open={open} setOpen={updateOpen}>
<Col className="gap-4 rounded-md bg-white px-8 py-6">
{children}
<Row className="gap-4">
<button
className={clsx('btn', cancelBtn?.className)}
onClick={() => setOpen(false)}
<div
className={clsx('btn normal-case', cancelBtn?.className)}
onClick={() => updateOpen(false)}
>
{cancelBtn?.label ?? 'Cancel'}
</button>
<button
className={clsx('btn', submitBtn?.className)}
onClick={onSubmit}
</div>
<div
className={clsx('btn normal-case', submitBtn?.className)}
onClick={
onSubmitWithSuccess
? () =>
onSubmitWithSuccess().then((success) =>
updateOpen(!success)
)
: onSubmit
}
>
{submitBtn?.label ?? 'Submit'}
</button>
</div>
</Row>
</Col>
</Modal>
<button
className={clsx('btn', openModalBtn.className)}
onClick={() => setOpen(true)}
<div
className={clsx('btn normal-case', openModalBtn.className)}
onClick={() => updateOpen(true)}
>
{openModalBtn.icon}
{openModalBtn.label}
</button>
</div>
</>
)
}

View File

@ -31,8 +31,10 @@ export function ContractCard(props: {
showCloseTime?: boolean
className?: string
onClick?: () => void
hideQuickBet?: boolean
}) {
const { showHotVolume, showCloseTime, className, onClick } = props
const { showHotVolume, showCloseTime, className, onClick, hideQuickBet } =
props
const contract = useContractWithPreload(props.contract) ?? props.contract
const { question, outcomeType } = contract
const { resolution } = contract
@ -42,12 +44,14 @@ export function ContractCard(props: {
const marketClosed =
(contract.closeTime || Infinity) < Date.now() || !!resolution
const showQuickBet = !(
!user ||
marketClosed ||
(outcomeType === 'FREE_RESPONSE' && getTopAnswer(contract) === undefined) ||
outcomeType === 'NUMERIC'
)
const showQuickBet =
user &&
!marketClosed &&
!(
outcomeType === 'FREE_RESPONSE' && getTopAnswer(contract) === undefined
) &&
outcomeType !== 'NUMERIC' &&
!hideQuickBet
return (
<div>

View File

@ -3,6 +3,7 @@ import {
DatabaseIcon,
PencilIcon,
TrendingUpIcon,
UserGroupIcon,
} from '@heroicons/react/outline'
import { Row } from '../layout/row'
import { formatMoney } from 'common/util/format'
@ -24,6 +25,8 @@ import NewContractBadge from '../new-contract-badge'
import { CATEGORY_LIST } from 'common/categories'
import { TagsList } from '../tags-list'
import { UserFollowButton } from '../follow-button'
import { groupPath } from 'web/lib/firebase/groups'
import { SiteLink } from 'web/components/site-link'
import { DAY_MS } from 'common/util/time'
export function MiscDetails(props: {
@ -107,7 +110,8 @@ export function ContractDetails(props: {
disabled?: boolean
}) {
const { contract, bets, isCreator, disabled } = props
const { closeTime, creatorName, creatorUsername, creatorId } = contract
const { closeTime, creatorName, creatorUsername, creatorId, groupDetails } =
contract
const { volumeLabel, resolvedDate } = contractMetrics(contract)
return (
@ -130,18 +134,21 @@ export function ContractDetails(props: {
)}
{!disabled && <UserFollowButton userId={creatorId} small />}
</Row>
{groupDetails && (
<Row className={'line-clamp-1 mt-1 max-w-[200px]'}>
<SiteLink href={`${groupPath(groupDetails[0].groupSlug)}`}>
<UserGroupIcon className="mx-1 mb-1 inline h-5 w-5" />
<span>{groupDetails[0].groupName}</span>
</SiteLink>
</Row>
)}
{(!!closeTime || !!resolvedDate) && (
<Row className="items-center gap-1">
<ClockIcon className="h-5 w-5" />
{/* <DateTimeTooltip text="Market created:" time={contract.createdTime}>
{createdDate}
</DateTimeTooltip> */}
{resolvedDate && contract.resolutionTime ? (
<>
{/* {' - '} */}
<DateTimeTooltip
text="Market resolved:"
time={contract.resolutionTime}
@ -153,7 +160,6 @@ export function ContractDetails(props: {
{!resolvedDate && closeTime && (
<>
{/* {' - '}{' '} */}
<EditableCloseDate
closeTime={closeTime}
contract={contract}

View File

@ -18,10 +18,10 @@ import { Col } from '../layout/col'
import { Modal } from '../layout/modal'
import { Row } from '../layout/row'
import { ShareEmbedButton } from '../share-embed-button'
import { TagsInput } from '../tags-input'
import { Title } from '../title'
import { TweetButton } from '../tweet-button'
import { InfoTooltip } from '../info-tooltip'
import { TagsInput } from 'web/components/tags-input'
export function ContractInfoDialog(props: { contract: Contract; bets: Bet[] }) {
const { contract, bets } = props
@ -150,7 +150,6 @@ export function ContractInfoDialog(props: { contract: Contract; bets: Bet[] }) {
<div>Tags</div>
<TagsInput contract={contract} />
<div />
{contract.mechanism === 'cpmm-1' && !contract.resolution && (
<LiquidityPanel contract={contract} />
)}

View File

@ -6,6 +6,7 @@ import { ContractCard } from './contract-card'
import { ContractSearch } from '../contract-search'
import { useIsVisible } from 'web/hooks/use-is-visible'
import { useEffect, useState } from 'react'
import clsx from 'clsx'
export function ContractsGrid(props: {
contracts: Contract[]
@ -13,8 +14,18 @@ export function ContractsGrid(props: {
hasMore: boolean
showCloseTime?: boolean
onContractClick?: (contract: Contract) => void
overrideGridClassName?: string
hideQuickBet?: boolean
}) {
const { contracts, showCloseTime, hasMore, loadMore, onContractClick } = props
const {
contracts,
showCloseTime,
hasMore,
loadMore,
onContractClick,
overrideGridClassName,
hideQuickBet,
} = props
const [elem, setElem] = useState<HTMLElement | null>(null)
const isBottomVisible = useIsVisible(elem)
@ -38,7 +49,13 @@ export function ContractsGrid(props: {
return (
<Col className="gap-8">
<ul className="grid w-full grid-cols-1 gap-4 md:grid-cols-2">
<ul
className={clsx(
overrideGridClassName
? overrideGridClassName
: 'grid w-full grid-cols-1 gap-4 md:grid-cols-2'
)}
>
{contracts.map((contract) => (
<ContractCard
contract={contract}
@ -47,6 +64,7 @@ export function ContractsGrid(props: {
onClick={
onContractClick ? () => onContractClick(contract) : undefined
}
hideQuickBet={hideQuickBet}
/>
))}
</ul>

View File

@ -0,0 +1,34 @@
import Link from 'next/link'
import clsx from 'clsx'
import { firebaseLogin, User } from 'web/lib/firebase/users'
import React from 'react'
export const CreateQuestionButton = (props: {
user: User | null | undefined
overrideText?: string
className?: string
query?: string
}) => {
const gradient =
'from-indigo-500 to-blue-500 hover:from-indigo-700 hover:to-blue-700'
const buttonStyle =
'border-w-0 mx-auto mt-4 -ml-1 w-full rounded-md bg-gradient-to-r py-2.5 text-base font-semibold text-white shadow-sm lg:-ml-0'
const { user, overrideText, className, query } = props
return (
<div className={clsx('aligncenter flex justify-center', className)}>
{user ? (
<Link href={`/create${query ? query : ''}`} passHref>
<button className={clsx(gradient, buttonStyle)}>
{overrideText ? overrideText : 'Create a question'}
</button>
</Link>
) : (
<button onClick={firebaseLogin} className={clsx(gradient, buttonStyle)}>
Sign in
</button>
)}
</div>
)
}

View File

@ -9,26 +9,20 @@ import { LinkIcon } from '@heroicons/react/outline'
import clsx from 'clsx'
export function CopyLinkDateTimeComponent(props: {
contractCreatorUsername: string
contractSlug: string
prefix: string
slug: string
createdTime: number
elementId: string
className?: string
}) {
const {
contractCreatorUsername,
contractSlug,
elementId,
createdTime,
className,
} = props
const { prefix, slug, elementId, createdTime, className } = props
const [showToast, setShowToast] = useState(false)
function copyLinkToComment(
event: React.MouseEvent<HTMLAnchorElement, MouseEvent>
) {
event.preventDefault()
const elementLocation = `https://${ENV_CONFIG.domain}/${contractCreatorUsername}/${contractSlug}#${elementId}`
const elementLocation = `https://${ENV_CONFIG.domain}/${prefix}/${slug}#${elementId}`
copyToClipboard(elementLocation)
setShowToast(true)
@ -37,10 +31,7 @@ export function CopyLinkDateTimeComponent(props: {
return (
<div className={clsx('inline', className)}>
<DateTimeTooltip time={createdTime}>
<Link
href={`/${contractCreatorUsername}/${contractSlug}#${elementId}`}
passHref={true}
>
<Link href={`/${prefix}/${slug}#${elementId}`} passHref={true}>
<a
onClick={(event) => copyLinkToComment(event)}
className={'mx-1 cursor-pointer'}

View File

@ -152,8 +152,8 @@ export function FeedAnswerCommentGroup(props: {
<div className="text-sm text-gray-500">
<UserLink username={username} name={name} /> answered
<CopyLinkDateTimeComponent
contractCreatorUsername={contract.creatorUsername}
contractSlug={contract.slug}
prefix={contract.creatorUsername}
slug={contract.slug}
createdTime={answer.createdTime}
elementId={answerElementId}
/>
@ -234,7 +234,10 @@ export function FeedAnswerCommentGroup(props: {
parentAnswerOutcome={answer.number.toString()}
replyToUsername={replyToUsername}
setRef={setInputRef}
onSubmitComment={() => setShowReply(false)}
onSubmitComment={() => {
setShowReply(false)
setReplyToUsername('')
}}
/>
</div>
)}

View File

@ -15,7 +15,10 @@ import { OutcomeLabel } from 'web/components/outcome-label'
import { CopyLinkDateTimeComponent } from 'web/components/feed/copy-link-date-time'
import { contractPath } from 'web/lib/firebase/contracts'
import { firebaseLogin } from 'web/lib/firebase/users'
import { createComment, MAX_COMMENT_LENGTH } from 'web/lib/firebase/comments'
import {
createCommentOnContract,
MAX_COMMENT_LENGTH,
} from 'web/lib/firebase/comments'
import Textarea from 'react-expanding-textarea'
import { Linkify } from 'web/components/linkify'
import { SiteLink } from 'web/components/site-link'
@ -25,6 +28,7 @@ import { getProbability } from 'common/calculate'
import { LoadingIndicator } from 'web/components/loading-indicator'
import { PaperAirplaneIcon } from '@heroicons/react/outline'
import { track } from 'web/lib/service/analytics'
import { useEvent } from 'web/hooks/use-event'
import { Tipper } from '../tipper'
import { CommentTipMap, CommentTips } from 'web/hooks/use-tip-txns'
@ -96,7 +100,10 @@ export function FeedCommentThread(props: {
replyToUsername={replyToUsername}
parentAnswerOutcome={comments[0].answerOutcome}
setRef={setInputRef}
onSubmitComment={() => setShowReply(false)}
onSubmitComment={() => {
setShowReply(false)
setReplyToUsername('')
}}
/>
</div>
)}
@ -262,8 +269,8 @@ export function FeedComment(props: {
)}
</>
<CopyLinkDateTimeComponent
contractCreatorUsername={contract.creatorUsername}
contractSlug={contract.slug}
prefix={contract.creatorUsername}
slug={contract.slug}
createdTime={createdTime}
elementId={comment.id}
/>
@ -332,6 +339,7 @@ function CommentStatus(props: {
)
}
//TODO: move commentinput and comment input text area into their own files
export function CommentInput(props: {
contract: Contract
betsByCurrentUser: Bet[]
@ -366,12 +374,6 @@ export function CommentInput(props: {
)
const { id } = mostRecentCommentableBet || { id: undefined }
useEffect(() => {
if (!replyToUsername || !user || replyToUsername === user.username) return
const replacement = `@${replyToUsername} `
setComment((comment) => replacement + comment.replace(replacement, ''))
}, [user, replyToUsername])
async function submitComment(betId: string | undefined) {
if (!user) {
track('sign in to comment')
@ -379,7 +381,7 @@ export function CommentInput(props: {
}
if (!comment || isSubmitting) return
setIsSubmitting(true)
await createComment(
await createCommentOnContract(
contract.id,
comment,
user,
@ -403,7 +405,7 @@ export function CommentInput(props: {
return (
<>
<Row className={'mb-2 gap-1 sm:gap-2'}>
<div className={''}>
<div className={'mt-2'}>
<Avatar
avatarUrl={user?.avatarUrl}
username={user?.username}
@ -442,46 +444,98 @@ export function CommentInput(props: {
</>
)}
</div>
<CommentInputTextArea
commentText={comment}
setComment={setComment}
isReply={!!parentCommentId || !!parentAnswerOutcome}
replyToUsername={replyToUsername ?? ''}
user={user}
submitComment={submitComment}
isSubmitting={isSubmitting}
setRef={setRef}
presetId={id}
/>
</div>
</div>
</Row>
</>
)
}
export function CommentInputTextArea(props: {
user: User | undefined | null
isReply: boolean
replyToUsername: string
commentText: string
setComment: (text: string) => void
submitComment: (id?: string) => void
isSubmitting: boolean
setRef?: (ref: HTMLTextAreaElement) => void
presetId?: string
enterToSubmit?: boolean
}) {
const {
isReply,
setRef,
user,
commentText,
setComment,
submitComment,
presetId,
isSubmitting,
replyToUsername,
enterToSubmit,
} = props
const memoizedSetComment = useEvent(setComment)
useEffect(() => {
if (!replyToUsername || !user || replyToUsername === user.username) return
const replacement = `@${replyToUsername} `
memoizedSetComment(replacement + commentText.replace(replacement, ''))
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [user, replyToUsername, memoizedSetComment])
return (
<>
<Row className="gap-1.5 text-gray-700">
<Textarea
ref={setRef}
value={comment}
value={commentText}
onChange={(e) => setComment(e.target.value)}
className={clsx(
'textarea textarea-bordered w-full resize-none'
)}
className={clsx('textarea textarea-bordered w-full resize-none')}
// Make room for floating submit button.
style={{ paddingRight: 48 }}
placeholder={
parentCommentId || parentAnswerOutcome
isReply
? 'Write a reply... '
: enterToSubmit
? 'Send a message'
: 'Write a comment...'
}
autoFocus={false}
maxLength={MAX_COMMENT_LENGTH}
disabled={isSubmitting}
onKeyDown={(e) => {
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
if (
(enterToSubmit && e.key === 'Enter' && !e.shiftKey) ||
(e.key === 'Enter' && (e.ctrlKey || e.metaKey))
) {
e.preventDefault()
submitComment(id)
submitComment(presetId)
e.currentTarget.blur()
}
}}
/>
<Col className={clsx('justify-end')}>
<Col className={clsx('relative justify-end')}>
{user && !isSubmitting && (
<button
className={clsx(
'btn btn-ghost btn-sm absolute right-2 flex-row pl-2 capitalize',
parentCommentId || parentAnswerOutcome
? ' bottom-4'
: ' bottom-2',
!comment && 'pointer-events-none text-gray-500'
isReply ? ' bottom-4' : ' bottom-2',
!commentText && 'pointer-events-none text-gray-500'
)}
onClick={() => {
submitComment(id)
submitComment(presetId)
}}
>
<PaperAirplaneIcon
@ -499,15 +553,12 @@ export function CommentInput(props: {
{!user && (
<button
className={'btn btn-outline btn-sm mt-2 normal-case'}
onClick={() => submitComment(id)}
onClick={() => submitComment(presetId)}
>
Sign in to comment
</button>
)}
</Row>
</div>
</div>
</Row>
</>
)
}

View File

@ -39,9 +39,11 @@ export function findActiveContracts(
// Add every contract that had a recent comment, too
for (const comment of recentComments) {
if (comment.contractId) {
const contract = contractsById.get(comment.contractId)
if (contract) record(contract.id, comment.createdTime)
}
}
// Add contracts by last bet time.
const contractBets = groupBy(recentBets, (bet) => bet.contractId)

View File

@ -0,0 +1,118 @@
import { UserIcon } from '@heroicons/react/outline'
import { useUsers } from 'web/hooks/use-users'
import { User } from 'common/user'
import { Fragment, useState } from 'react'
import clsx from 'clsx'
import { Menu, Transition } from '@headlessui/react'
import { Avatar } from 'web/components/avatar'
import { Row } from 'web/components/layout/row'
export function FilterSelectUsers(props: {
setSelectedUsers: (users: User[]) => void
selectedUsers: User[]
ignoreUserIds: string[]
}) {
const { ignoreUserIds, selectedUsers, setSelectedUsers } = props
const users = useUsers()
const [query, setQuery] = useState('')
const filteredUsers =
query === ''
? users
: users.filter((user: User) => {
return (
!selectedUsers.map((user) => user.name).includes(user.name) &&
!ignoreUserIds.includes(user.id) &&
user.name.toLowerCase().includes(query.toLowerCase())
)
})
return (
<div>
<div className="relative mt-1 rounded-md">
<div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
<UserIcon className="h-5 w-5 text-gray-400" aria-hidden="true" />
</div>
<input
type="text"
name="user name"
id="user name"
value={query}
onChange={(e) => setQuery(e.target.value)}
className="input input-bordered block w-full pl-10 focus:border-gray-300 "
placeholder="Austin Chen"
/>
</div>
<Menu
as="div"
className={clsx(
'relative inline-block w-full text-right',
query !== '' && 'h-36'
)}
>
{({}) => (
<Transition
show={query !== ''}
as={Fragment}
enter="transition ease-out duration-100"
enterFrom="transform opacity-0 scale-95"
enterTo="transform opacity-100 scale-100"
leave="transition ease-in duration-75"
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
>
<Menu.Items
static={true}
className="absolute right-0 mt-2 w-full origin-top-right cursor-pointer divide-y divide-gray-100 rounded-md bg-white shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none"
>
<div className="py-1">
{filteredUsers.map((user: User) => (
<Menu.Item key={user.id}>
{({ active }) => (
<span
className={clsx(
active
? 'bg-gray-100 text-gray-900'
: 'text-gray-700',
'group flex items-center px-4 py-2 text-sm'
)}
onClick={() => {
setQuery('')
setSelectedUsers([...selectedUsers, user])
}}
>
<Avatar
username={user.username}
avatarUrl={user.avatarUrl}
size={'xs'}
className={'mr-2'}
/>
{user.name}
</span>
)}
</Menu.Item>
))}
</div>
</Menu.Items>
</Transition>
)}
</Menu>
{selectedUsers.length > 0 && (
<>
<div className={'mb-2'}>Added members:</div>
<Row className="mt-0 grid grid-cols-6 gap-2">
{selectedUsers.map((user: User) => (
<div key={user.id} className="col-span-2 flex items-center">
<Avatar
username={user.username}
avatarUrl={user.avatarUrl}
size={'sm'}
/>
<span className="ml-2">{user.name}</span>
</div>
))}
</Row>
</>
)}
</div>
)
}

View File

@ -1,141 +0,0 @@
import clsx from 'clsx'
import { useRouter } from 'next/router'
import { useState } from 'react'
import { PlusCircleIcon } from '@heroicons/react/solid'
import { parseWordsAsTags } from 'common/util/parse'
import { createFold } from 'web/lib/firebase/fn-call'
import { foldPath } from 'web/lib/firebase/folds'
import { toCamelCase } from 'common/util/format'
import { ConfirmationButton } from '../confirmation-button'
import { Col } from '../layout/col'
import { Spacer } from '../layout/spacer'
import { TagsList } from '../tags-list'
import { Title } from '../title'
export function CreateFoldButton() {
const [name, setName] = useState('')
const [about, setAbout] = useState('')
const [otherTags, setOtherTags] = useState('')
const [isSubmitting, setIsSubmitting] = useState(false)
const router = useRouter()
const tags = parseWordsAsTags(toCamelCase(name) + ' ' + otherTags)
const updateName = (newName: string) => {
setName(newName)
}
const onSubmit = async () => {
setIsSubmitting(true)
const result = await createFold({
name,
tags,
about,
}).then((r) => r.data || {})
if (result.fold) {
await router.push(foldPath(result.fold)).catch((e) => {
console.log(e)
setIsSubmitting(false)
})
} else {
console.log(result.status, result.message)
setIsSubmitting(false)
}
}
return (
<ConfirmationButton
openModalBtn={{
label: 'New',
icon: <PlusCircleIcon className="mr-2 h-5 w-5" />,
className: clsx(
isSubmitting ? 'loading btn-disabled' : 'btn-primary',
'btn-sm'
),
}}
submitBtn={{
label: 'Create',
className: clsx(name ? 'btn-primary' : 'btn-disabled'),
}}
onSubmit={onSubmit}
>
<Title className="!mt-0" text="Create a community" />
<Col className="gap-1 text-gray-500">
<div>
Markets are included in a community if they match one or more tags.
</div>
</Col>
<Spacer h={4} />
<div>
<div className="form-control w-full">
<label className="label">
<span className="mb-1">Community name</span>
</label>
<input
placeholder="Name"
className="input input-bordered resize-none"
disabled={isSubmitting}
value={name}
maxLength={140}
onChange={(e) => updateName(e.target.value || '')}
/>
</div>
<Spacer h={4} />
<div className="form-control w-full">
<label className="label">
<span className="mb-1">About</span>
</label>
<input
placeholder="Short description (140 characters max, optional)"
className="input input-bordered resize-none"
disabled={isSubmitting}
value={about}
maxLength={140}
onChange={(e) => setAbout(e.target.value || '')}
/>
</div>
<Spacer h={4} />
<label className="label">
<span className="mb-1">Primary tag</span>
</label>
<TagsList noLink noLabel tags={[`#${toCamelCase(name)}`]} />
<Spacer h={4} />
<div className="form-control w-full">
<label className="label">
<span className="mb-1">Additional tags</span>
</label>
<input
placeholder="Politics, Economics, Rationality (Optional)"
className="input input-bordered resize-none"
disabled={isSubmitting}
value={otherTags}
onChange={(e) => setOtherTags(e.target.value || '')}
/>
</div>
<Spacer h={4} />
<TagsList
tags={parseWordsAsTags(otherTags).map((tag) => `#${tag}`)}
noLink
noLabel
/>
</div>
</ConfirmationButton>
)
}

View File

@ -1,110 +0,0 @@
import clsx from 'clsx'
import { useState } from 'react'
import { SearchIcon } from '@heroicons/react/outline'
import { User } from 'common/user'
import {
followFoldFromSlug,
unfollowFoldFromSlug,
} from 'web/lib/firebase/folds'
import { Row } from '../layout/row'
import { Spacer } from '../layout/spacer'
function FollowFoldButton(props: {
fold: { slug: string; name: string }
user: User | null | undefined
isFollowed?: boolean
}) {
const { fold, user, isFollowed } = props
const { slug, name } = fold
const [followed, setFollowed] = useState(isFollowed)
const onClick = async () => {
if (followed) {
if (user) await unfollowFoldFromSlug(slug, user.id)
setFollowed(false)
} else {
if (user) await followFoldFromSlug(slug, user.id)
setFollowed(true)
}
}
return (
<div
className={clsx(
'rounded-full border-2 px-4 py-1 shadow-md',
'cursor-pointer',
followed ? 'border-gray-300 bg-gray-300' : 'bg-white'
)}
onClick={onClick}
>
<span className="text-sm text-gray-500">{name}</span>
</div>
)
}
function FollowFolds(props: {
folds: { slug: string; name: string }[]
followedFoldSlugs: string[]
noLabel?: boolean
className?: string
user: User | null | undefined
}) {
const { folds, noLabel, className, user, followedFoldSlugs } = props
return (
<Row className={clsx('flex-wrap items-center gap-2', className)}>
{folds.length > 0 && (
<>
{!noLabel && <div className="mr-1 text-gray-500">Communities</div>}
{folds.map((fold) => (
<FollowFoldButton
key={fold.slug + followedFoldSlugs.length}
user={user}
fold={fold}
isFollowed={followedFoldSlugs.includes(fold.slug)}
/>
))}
</>
)}
</Row>
)
}
export const FastFoldFollowing = (props: {
followedFoldSlugs: string[]
user: User | null | undefined
}) => {
const { followedFoldSlugs, user } = props
return (
<>
<Row className="mx-3 mb-3 items-center gap-2 text-sm text-gray-800">
<SearchIcon className="inline h-5 w-5" aria-hidden="true" />
Personalize your feed click on a community to follow
</Row>
<FollowFolds
className="mx-2"
noLabel
user={user}
followedFoldSlugs={followedFoldSlugs}
folds={[
{ name: 'Russia/Ukraine', slug: 'russia-ukraine' },
{ name: 'Crypto', slug: 'crypto' },
{ name: 'Sports', slug: 'sports' },
{ name: 'Science', slug: 'science' },
{ name: 'Covid', slug: 'covid' },
{ name: 'AI', slug: 'ai' },
{
name: 'Manifold Markets',
slug: 'manifold-markets',
},
]}
/>
<Spacer h={5} />
</>
)
}

View File

@ -1,17 +0,0 @@
import clsx from 'clsx'
import { Fold } from 'common/fold'
export function FoldTag(props: { fold: Fold }) {
const { fold } = props
const { name } = fold
return (
<div
className={clsx(
'rounded-full bg-white px-4 py-2 shadow-md hover:bg-gray-100',
'cursor-pointer'
)}
>
<span className="text-gray-500">{name}</span>
</div>
)
}

View File

@ -1,33 +0,0 @@
import { Fold } from 'common/fold'
import { useFollowedFoldIds } from 'web/hooks/use-fold'
import { useUser } from 'web/hooks/use-user'
import { followFold, unfollowFold } from 'web/lib/firebase/folds'
import { FollowButton } from '../follow-button'
export function FollowFoldButton(props: { fold: Fold; className?: string }) {
const { fold, className } = props
const user = useUser()
const followedFoldIds = useFollowedFoldIds(user)
const isFollowing = followedFoldIds
? followedFoldIds.includes(fold.id)
: undefined
const onFollow = () => {
if (user) followFold(fold.id, user.id)
}
const onUnfollow = () => {
if (user) unfollowFold(fold, user)
}
return (
<FollowButton
isFollowing={isFollowing}
onFollow={onFollow}
onUnfollow={onUnfollow}
className={className}
/>
)
}

View File

@ -0,0 +1,140 @@
import clsx from 'clsx'
import { useRouter } from 'next/router'
import { useState } from 'react'
import { groupPath } from 'web/lib/firebase/groups'
import { ConfirmationButton } from '../confirmation-button'
import { Col } from '../layout/col'
import { Spacer } from '../layout/spacer'
import { Title } from '../title'
import { FilterSelectUsers } from 'web/components/filter-select-users'
import { User } from 'common/user'
import { MAX_GROUP_NAME_LENGTH } from 'common/group'
import { createGroup } from 'web/lib/firebase/api-call'
export function CreateGroupButton(props: {
user: User
className?: string
label?: string
onOpenStateChange?: (isOpen: boolean) => void
goToGroupOnSubmit?: boolean
icon?: JSX.Element
}) {
const { user, className, label, onOpenStateChange, goToGroupOnSubmit, icon } =
props
const [defaultName, setDefaultName] = useState(`${user.name}'s group`)
const [name, setName] = useState('')
const [memberUsers, setMemberUsers] = useState<User[]>([])
const [isSubmitting, setIsSubmitting] = useState(false)
const [errorText, setErrorText] = useState('')
const router = useRouter()
function updateMemberUsers(users: User[]) {
const usersFirstNames = users.map((user) => user.name.split(' ')[0])
const postFix =
usersFirstNames.length > 3 ? ` & ${usersFirstNames.length - 3} more` : ''
const newName = `${user.name.split(' ')[0]}${
users.length > 0 ? ', ' + usersFirstNames.slice(0, 3).join(', ') : ''
}${postFix}'s group`
setDefaultName(newName)
setMemberUsers(users)
}
const onSubmit = async () => {
setIsSubmitting(true)
const groupName = name !== '' ? name : defaultName
const newGroup = {
name: groupName,
memberIds: memberUsers.map((user) => user.id),
anyoneCanJoin: false,
}
const result = await createGroup(newGroup).catch((e) => {
const errorDetails = e.details[0]
if (errorDetails)
setErrorText(
`Error with ${errorDetails.field} field - ${errorDetails.error} `
)
else setErrorText(e.message)
setIsSubmitting(false)
console.error(e)
return e
})
console.log(result.details)
if (result.group) {
updateMemberUsers([])
if (goToGroupOnSubmit)
router.push(groupPath(result.group.slug)).catch((e) => {
console.log(e)
setErrorText(e.message)
})
setIsSubmitting(false)
return true
} else {
setIsSubmitting(false)
return false
}
}
return (
<ConfirmationButton
openModalBtn={{
label: label ? label : 'Create Group',
icon: icon,
className: clsx(
isSubmitting ? 'loading btn-disabled' : 'btn-primary',
'btn-sm, normal-case',
className
),
}}
submitBtn={{
label: 'Create',
className: clsx(
'normal-case',
isSubmitting ? 'loading btn-disabled' : ' btn-primary'
),
}}
onSubmitWithSuccess={onSubmit}
onOpenChanged={(isOpen) => {
onOpenStateChange?.(isOpen)
updateMemberUsers([])
setName('')
}}
>
<Title className="!my-0" text="Create a group" />
<Col className="gap-1 text-gray-500">
<div>You can add markets and members to your group after creation.</div>
</Col>
<div className={'text-error'}>{errorText}</div>
<div>
<div className="form-control w-full">
<label className="label">
<span className="mb-0">Add members (optional)</span>
</label>
<FilterSelectUsers
setSelectedUsers={updateMemberUsers}
selectedUsers={memberUsers}
ignoreUserIds={[user.id]}
/>
</div>
<div className="form-control w-full">
<label className="label">
<span className="mt-1">Group name (optional)</span>
</label>
<input
placeholder={defaultName}
className="input input-bordered resize-none"
disabled={isSubmitting}
value={name}
maxLength={MAX_GROUP_NAME_LENGTH}
onChange={(e) => setName(e.target.value || '')}
/>
</div>
<Spacer h={4} />
</div>
</ConfirmationButton>
)
}

View File

@ -0,0 +1,185 @@
import { Row } from 'web/components/layout/row'
import { Col } from 'web/components/layout/col'
import { User } from 'common/user'
import React, { useEffect, memo, useState } from 'react'
import { Avatar } from 'web/components/avatar'
import { Group } from 'common/group'
import { Comment, createCommentOnGroup } from 'web/lib/firebase/comments'
import {
CommentInputTextArea,
TruncatedComment,
} from 'web/components/feed/feed-comments'
import { track } from 'web/lib/service/analytics'
import { firebaseLogin } from 'web/lib/firebase/users'
import { useRouter } from 'next/router'
import clsx from 'clsx'
import { UserLink } from 'web/components/user-page'
import { groupPath } from 'web/lib/firebase/groups'
import { CopyLinkDateTimeComponent } from 'web/components/feed/copy-link-date-time'
export function Discussion(props: {
messages: Comment[]
user: User | null | undefined
group: Group
}) {
const { messages, user, group } = props
const [messageText, setMessageText] = useState('')
const [isSubmitting, setIsSubmitting] = useState(false)
const [scrollToBottomRef, setScrollToBottomRef] =
useState<HTMLDivElement | null>(null)
const [scrollToMessageId, setScrollToMessageId] = useState('')
const [scrollToMessageRef, setScrollToMessageRef] =
useState<HTMLDivElement | null>(null)
const [replyToUsername, setReplyToUsername] = useState('')
const router = useRouter()
useEffect(() => {
scrollToMessageRef?.scrollIntoView()
}, [scrollToMessageRef])
useEffect(() => {
scrollToBottomRef?.scrollIntoView()
}, [isSubmitting, scrollToBottomRef])
useEffect(() => {
const elementInUrl = router.asPath.split('#')[1]
if (messages.map((m) => m.id).includes(elementInUrl)) {
setScrollToMessageId(elementInUrl)
}
}, [messages, router.asPath])
function onReplyClick(comment: Comment) {
setReplyToUsername(comment.userUsername)
}
async function submitMessage() {
if (!user) {
track('sign in to comment')
return await firebaseLogin()
}
if (!messageText || isSubmitting) return
setIsSubmitting(true)
await createCommentOnGroup(group.id, messageText, user)
setMessageText('')
setIsSubmitting(false)
setReplyToUsername('')
}
return (
<Col className={'flex-1'}>
<Col
className={
'max-h-[65vh] w-full space-y-2 overflow-x-hidden overflow-y-scroll'
}
>
{messages.map((message, i) => (
<GroupMessage
user={user}
key={message.id}
comment={message}
group={group}
onReplyClick={onReplyClick}
highlight={message.id === scrollToMessageId}
setRef={
scrollToMessageId === message.id
? setScrollToMessageRef
: i === messages.length - 1
? setScrollToBottomRef
: undefined
}
/>
))}
{messages.length === 0 && (
<div className="p-2 text-gray-500">
No messages yet. 🦗... Why not say something?
</div>
)}
</Col>
{user && group.memberIds.includes(user.id) && (
<div className=" flex w-full justify-start gap-2 p-2">
<div className="mt-1">
<Avatar
username={user?.username}
avatarUrl={user?.avatarUrl}
size={'sm'}
/>
</div>
<div className={'flex-1'}>
<CommentInputTextArea
commentText={messageText}
setComment={setMessageText}
isReply={false}
user={user}
replyToUsername={replyToUsername}
submitComment={submitMessage}
isSubmitting={isSubmitting}
enterToSubmit={true}
/>
</div>
</div>
)}
</Col>
)
}
const GroupMessage = memo(function GroupMessage_(props: {
user: User | null | undefined
comment: Comment
group: Group
truncate?: boolean
smallAvatar?: boolean
onReplyClick?: (comment: Comment) => void
setRef?: (ref: HTMLDivElement) => void
highlight?: boolean
}) {
const { comment, truncate, onReplyClick, group, setRef, highlight, user } =
props
const { text, userUsername, userName, userAvatarUrl, createdTime } = comment
return (
<Row
ref={setRef}
className={clsx(
comment.userId === user?.id ? 'mr-2 self-end' : ' ml-2',
'w-fit space-x-1.5 rounded-md bg-white p-2 px-4 transition-all duration-1000 sm:space-x-3',
highlight ? `-m-1 bg-indigo-500/[0.2] p-2` : ''
)}
>
<Avatar
className={'ml-1'}
size={'sm'}
username={userUsername}
avatarUrl={userAvatarUrl}
/>
<div className="w-full">
<div className="mt-0.5 pl-0.5 text-sm text-gray-500">
<UserLink
className="text-gray-500"
username={userUsername}
name={userName}
/>{' '}
<CopyLinkDateTimeComponent
prefix={'group'}
slug={group.slug}
createdTime={createdTime}
elementId={comment.id}
/>
</div>
<TruncatedComment
comment={text}
moreHref={groupPath(group.slug)}
shouldTruncate={truncate}
/>
{onReplyClick && (
<button
className={'text-xs font-bold text-gray-500 hover:underline'}
onClick={() => onReplyClick(comment)}
>
Reply
</button>
)}
</div>
</Row>
)
})

View File

@ -1,71 +1,66 @@
import { useState } from 'react'
import { isEqual } from 'lodash'
import clsx from 'clsx'
import { PencilIcon } from '@heroicons/react/outline'
import { Fold } from 'common/fold'
import { parseWordsAsTags } from 'common/util/parse'
import { deleteFold, updateFold } from 'web/lib/firebase/folds'
import { toCamelCase } from 'common/util/format'
import { Group } from 'common/group'
import { deleteGroup, updateGroup } from 'web/lib/firebase/groups'
import { Spacer } from '../layout/spacer'
import { TagsList } from '../tags-list'
import { useRouter } from 'next/router'
import { Modal } from 'web/components/layout/modal'
import { FilterSelectUsers } from 'web/components/filter-select-users'
import { User } from 'common/user'
export function EditFoldButton(props: { fold: Fold; className?: string }) {
const { fold, className } = props
export function EditGroupButton(props: { group: Group; className?: string }) {
const { group, className } = props
const { memberIds } = group
const router = useRouter()
const [name, setName] = useState(fold.name)
const [about, setAbout] = useState(fold.about ?? '')
const initialOtherTags =
fold?.tags.filter((tag) => tag !== toCamelCase(name)).join(', ') ?? ''
const [otherTags, setOtherTags] = useState(initialOtherTags)
const [name, setName] = useState(group.name)
const [about, setAbout] = useState(group.about ?? '')
const [open, setOpen] = useState(false)
const [isSubmitting, setIsSubmitting] = useState(false)
const [addMemberUsers, setAddMemberUsers] = useState<User[]>([])
const tags = parseWordsAsTags(toCamelCase(name) + ' ' + otherTags)
const lowercaseTags = tags.map((tag) => tag.toLowerCase())
function updateOpen(newOpen: boolean) {
setAddMemberUsers([])
setOpen(newOpen)
}
const saveDisabled =
name === fold.name &&
isEqual(tags, fold.tags) &&
about === (fold.about ?? '')
name === group.name && about === group.about && addMemberUsers.length === 0
const onSubmit = async () => {
setIsSubmitting(true)
await updateFold(fold, {
await updateGroup(group, {
name,
about,
tags,
lowercaseTags,
memberIds: [...memberIds, ...addMemberUsers.map((user) => user.id)],
})
setIsSubmitting(false)
updateOpen(false)
}
return (
<div className={clsx('p-1', className)}>
<label
htmlFor="edit"
<div className={clsx('flex p-1', className)}>
<div
className={clsx(
'modal-button cursor-pointer whitespace-nowrap text-sm text-gray-700'
'btn-ghost cursor-pointer whitespace-nowrap rounded-full text-sm text-white'
)}
onClick={() => updateOpen(!open)}
>
<PencilIcon className="inline h-4 w-4" /> Edit
</label>
<input type="checkbox" id="edit" className="modal-toggle" />
<div className="modal">
<div className="modal-box">
</div>
<Modal open={open} setOpen={updateOpen}>
<div className="h-full rounded-md bg-white p-8">
<div className="form-control w-full">
<label className="label">
<span className="mb-1">Community name</span>
<span className="mb-1">Group name</span>
</label>
<input
placeholder="Your fold name"
placeholder="Your group name"
className="input input-bordered resize-none"
disabled={isSubmitting}
value={name}
@ -94,29 +89,23 @@ export function EditFoldButton(props: { fold: Fold; className?: string }) {
<div className="form-control w-full">
<label className="label">
<span className="mb-1">Tags</span>
<span className="mb-0">Add members</span>
</label>
<input
placeholder="Politics, Economics, Rationality"
className="input input-bordered resize-none"
disabled={isSubmitting}
value={otherTags}
onChange={(e) => setOtherTags(e.target.value || '')}
<FilterSelectUsers
setSelectedUsers={setAddMemberUsers}
selectedUsers={addMemberUsers}
ignoreUserIds={memberIds}
/>
</div>
<Spacer h={4} />
<TagsList tags={tags} noLink noLabel />
<Spacer h={4} />
<div className="modal-action">
<label
htmlFor="edit"
onClick={() => {
if (confirm('Are you sure you want to delete this fold?')) {
deleteFold(fold)
router.replace('/folds')
if (confirm('Are you sure you want to delete this group?')) {
deleteGroup(group)
updateOpen(false)
router.replace('/groups')
}
}}
className={clsx(
@ -125,7 +114,11 @@ export function EditFoldButton(props: { fold: Fold; className?: string }) {
>
Delete
</label>
<label htmlFor="edit" className={clsx('btn')}>
<label
htmlFor="edit"
className={'btn'}
onClick={() => updateOpen(false)}
>
Cancel
</label>
<label
@ -141,7 +134,7 @@ export function EditFoldButton(props: { fold: Fold; className?: string }) {
</label>
</div>
</div>
</div>
</Modal>
</div>
)
}

View File

@ -0,0 +1,139 @@
import { Group } from 'common/group'
import { Combobox } from '@headlessui/react'
import { InfoTooltip } from 'web/components/info-tooltip'
import {
CheckIcon,
PlusCircleIcon,
SelectorIcon,
} from '@heroicons/react/outline'
import clsx from 'clsx'
import { CreateGroupButton } from 'web/components/groups/create-group-button'
import { useState } from 'react'
import { useMemberGroups } from 'web/hooks/use-group'
import { User } from 'common/user'
export function GroupSelector(props: {
selectedGroup?: Group
setSelectedGroup: (group: Group) => void
creator: User | null | undefined
showSelector?: boolean
}) {
const { selectedGroup, setSelectedGroup, creator, showSelector } = props
const [isCreatingNewGroup, setIsCreatingNewGroup] = useState(false)
const [query, setQuery] = useState('')
const memberGroups = useMemberGroups(creator)
const filteredGroups = memberGroups
? query === ''
? memberGroups
: memberGroups.filter((group) => {
return group.name.toLowerCase().includes(query.toLowerCase())
})
: []
if (!showSelector || !creator) {
return (
<>
<div className={'label justify-start'}>
In Group:
{selectedGroup ? (
<span className=" ml-1.5 text-indigo-600">
{selectedGroup?.name}
</span>
) : (
<span className=" ml-1.5 text-sm text-gray-600">(None)</span>
)}
</div>
</>
)
}
return (
<div className="form-control items-start">
<Combobox
as="div"
value={selectedGroup}
onChange={setSelectedGroup}
nullable={true}
className={'text-sm'}
>
{({ open }) => (
<>
{!open && setQuery('')}
<Combobox.Label className="label justify-start gap-2 text-base">
Add to Group
<InfoTooltip text="Question will be displayed alongside the other questions in the group." />
</Combobox.Label>
<div className="relative mt-2">
<Combobox.Input
className="w-full rounded-md border border-gray-300 bg-white p-3 pl-4 pr-20 text-sm shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 "
onChange={(event) => setQuery(event.target.value)}
displayValue={(group: Group) => group && group.name}
placeholder={'None'}
/>
<Combobox.Button className="absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none">
<SelectorIcon
className="h-5 w-5 text-gray-400"
aria-hidden="true"
/>
</Combobox.Button>
<Combobox.Options
static={isCreatingNewGroup}
className="absolute z-10 mt-1 max-h-60 w-full overflow-x-hidden rounded-md bg-white py-1 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none"
>
{filteredGroups.map((group: Group) => (
<Combobox.Option
key={group.id}
value={group}
className={({ active }) =>
clsx(
'relative h-12 cursor-pointer select-none py-2 pl-4 pr-9',
active ? 'bg-indigo-500 text-white' : 'text-gray-900'
)
}
>
{({ active, selected }) => (
<>
{selected && (
<span
className={clsx(
'absolute inset-y-0 left-2 flex items-center pr-4',
active ? 'text-white' : 'text-indigo-600'
)}
>
<CheckIcon className="h-5 w-5" aria-hidden="true" />
</span>
)}
<span
className={clsx(
'ml-5 mt-1 block truncate',
selected && 'font-semibold'
)}
>
{group.name}
</span>
</>
)}
</Combobox.Option>
))}
<CreateGroupButton
user={creator}
onOpenStateChange={setIsCreatingNewGroup}
className={
'w-full justify-start rounded-none border-0 bg-white pl-2 font-normal text-gray-900 hover:bg-indigo-500 hover:text-white'
}
label={'Create a new Group'}
goToGroupOnSubmit={false}
icon={
<PlusCircleIcon className="text-primary mr-2 h-5 w-5" />
}
/>
</Combobox.Options>
</div>
</>
)}
</Combobox>
</div>
)
}

View File

@ -1,11 +1,15 @@
import clsx from 'clsx'
import React from 'react'
export function Col(props: JSX.IntrinsicElements['div']) {
export const Col = React.forwardRef(function Col(
props: JSX.IntrinsicElements['div'],
ref: React.Ref<HTMLDivElement>
) {
const { children, className, ...rest } = props
return (
<div className={clsx(className, 'flex flex-col')} {...rest}>
<div className={clsx(className, 'flex flex-col')} ref={ref} {...rest}>
{children}
</div>
)
}
})

View File

@ -1,11 +1,14 @@
import clsx from 'clsx'
import React from 'react'
export function Row(props: JSX.IntrinsicElements['div']) {
export const Row = React.forwardRef(function Row(
props: JSX.IntrinsicElements['div'],
ref: React.Ref<HTMLDivElement>
) {
const { children, className, ...rest } = props
return (
<div className={clsx(className, 'flex flex-row')} {...rest}>
<div className={clsx(className, 'flex flex-row')} ref={ref} {...rest}>
{children}
</div>
)
}
})

View File

@ -1,15 +1,24 @@
import { Fragment } from 'react'
import React, { Fragment } from 'react'
import { Menu, Transition } from '@headlessui/react'
import clsx from 'clsx'
export type MenuItem = {
name: string
href: string
onClick?: () => void
}
export function MenuButton(props: {
buttonContent: JSX.Element
menuItems: { name: string; href: string; onClick?: () => void }[]
menuItems: MenuItem[]
className?: string
}) {
const { buttonContent, menuItems, className } = props
return (
<Menu as="div" className={clsx('relative z-40 flex-shrink-0', className)}>
<Menu
as="div"
className={clsx(className ? className : 'relative z-40 flex-shrink-0')}
>
<div>
<Menu.Button className="w-full rounded-full">
<span className="sr-only">Open user menu</span>
@ -25,9 +34,9 @@ export function MenuButton(props: {
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
>
<Menu.Items className="absolute right-0 mt-2 w-40 origin-top-right rounded-md bg-white py-1 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none">
<Menu.Items className="absolute left-0 mt-2 w-40 origin-top-right rounded-md bg-white py-1 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none">
{menuItems.map((item) => (
<Menu.Item key={item.name}>
<Menu.Item key={item.href}>
{({ active }) => (
<a
href={item.href}
@ -35,7 +44,7 @@ export function MenuButton(props: {
onClick={item.onClick}
className={clsx(
active ? 'bg-gray-100' : '',
'block py-2 px-4 text-sm text-gray-700'
'line-clamp-3 block py-1.5 px-4 text-sm text-gray-700'
)}
>
{item.name}

View File

@ -9,13 +9,15 @@ import {
PresentationChartBarIcon,
SparklesIcon,
NewspaperIcon,
UserGroupIcon,
ChevronDownIcon,
TrendingUpIcon,
} from '@heroicons/react/outline'
import clsx from 'clsx'
import Link from 'next/link'
import { useRouter } from 'next/router'
import { useUser } from 'web/hooks/use-user'
import { firebaseLogin, firebaseLogout, User } from 'web/lib/firebase/users'
import { firebaseLogout, User } from 'web/lib/firebase/users'
import { ManifoldLogo } from './manifold-logo'
import { MenuButton } from './menu'
import { ProfileSummary } from './profile-menu'
@ -27,7 +29,11 @@ import { Row } from '../layout/row'
import NotificationsIcon from 'web/components/notifications-icon'
import React, { useEffect, useState } from 'react'
import { IS_PRIVATE_MANIFOLD } from 'common/envs/constants'
import { CreateQuestionButton } from 'web/components/create-question-button'
import { useMemberGroups } from 'web/hooks/use-group'
import { groupPath } from 'web/lib/firebase/groups'
import { trackCallback, withTracking } from 'web/lib/service/analytics'
import { Group } from 'common/group'
// Create an icon from the url of an image
function IconFromUrl(url: string): React.ComponentType<{ className?: string }> {
@ -117,7 +123,7 @@ const signedOutMobileNavigation = [
},
]
const mobileNavigation = [
const signedInMobileNavigation = [
{ name: 'Get M$', href: '/add-funds', icon: CashIcon },
...signedOutMobileNavigation,
]
@ -157,18 +163,36 @@ function SidebarItem(props: { item: Item; currentPage: string }) {
)
}
function MoreButton() {
function SidebarButton(props: {
text: string
icon: React.ComponentType<{ className?: string }>
children?: React.ReactNode
}) {
const { text, children } = props
return (
<a className="group flex items-center rounded-md px-3 py-2 text-sm font-medium text-gray-600 hover:cursor-pointer hover:bg-gray-100">
<DotsHorizontalIcon
<props.icon
className="-ml-1 mr-3 h-6 w-6 flex-shrink-0 text-gray-400 group-hover:text-gray-500"
aria-hidden="true"
/>
<span className="truncate">More</span>
<span className="truncate">{text}</span>
{children}
</a>
)
}
function MoreButton() {
return <SidebarButton text={'More'} icon={DotsHorizontalIcon} />
}
function GroupsButton() {
return (
<SidebarButton icon={UserGroupIcon} text={'Groups'}>
<ChevronDownIcon className=" mt-0.5 ml-2 h-5 w-5" aria-hidden="true" />
</SidebarButton>
)
}
export default function Sidebar(props: { className?: string }) {
const { className } = props
const router = useRouter()
@ -195,18 +219,16 @@ export default function Sidebar(props: { className?: string }) {
const user = useUser()
const mustWaitForFreeMarketStatus = useHasCreatedContractToday(user)
const navigationOptions =
user === null
const navigationOptions = !user
? signedOutNavigation
: getNavigation(user?.username || 'error')
const mobileNavigationOptions =
user === null ? signedOutMobileNavigation : mobileNavigation
const gradient =
'from-indigo-500 to-blue-500 hover:from-indigo-700 hover:to-blue-700'
const buttonStyle =
'border-w-0 mx-auto mt-4 -ml-1 w-full rounded-md bg-gradient-to-r py-2.5 text-base font-semibold text-white shadow-sm lg:-ml-0'
const mobileNavigationOptions = !user
? signedOutMobileNavigation
: signedInMobileNavigation
const memberItems = (useMemberGroups(user) ?? []).map((group: Group) => ({
name: group.name,
href: groupPath(group.slug),
}))
return (
<nav aria-label="Sidebar" className={className}>
@ -218,9 +240,23 @@ export default function Sidebar(props: { className?: string }) {
)}
<div className="space-y-1 lg:hidden">
{user && (
<MenuButton
buttonContent={<GroupsButton />}
menuItems={[{ name: 'Explore', href: '/groups' }, ...memberItems]}
className={'relative z-50 flex-shrink-0'}
/>
)}
{mobileNavigationOptions.map((item) => (
<SidebarItem key={item.name} item={item} currentPage={currentPage} />
<SidebarItem key={item.href} item={item} currentPage={currentPage} />
))}
{!user && (
<SidebarItem
key={'Groups'}
item={{ name: 'Groups', href: '/groups', icon: UserGroupIcon }}
currentPage={currentPage}
/>
)}
{user && (
<MenuButton
@ -237,35 +273,37 @@ export default function Sidebar(props: { className?: string }) {
</div>
<div className="hidden space-y-1 lg:block">
{navigationOptions.map((item) => (
<SidebarItem key={item.name} item={item} currentPage={currentPage} />
))}
{navigationOptions.map((item) =>
item.name === 'Notifications' ? (
<div key={item.href}>
<SidebarItem item={item} currentPage={currentPage} />
{user && (
<MenuButton
key={'groupsdropdown'}
buttonContent={<GroupsButton />}
menuItems={[
{ name: 'Explore', href: '/groups' },
...memberItems,
]}
className={'relative z-50 flex-shrink-0'}
/>
)}
</div>
) : (
<SidebarItem
key={item.href}
item={item}
currentPage={currentPage}
/>
)
)}
<MenuButton
menuItems={getMoreNavigation(user)}
buttonContent={<MoreButton />}
/>
</div>
<div className={'aligncenter flex justify-center'}>
{user ? (
<Link href={'/create'} passHref>
<button
className={clsx(gradient, buttonStyle)}
onClick={trackCallback('create question button')}
>
Create a question
</button>
</Link>
) : (
<button
onClick={withTracking(firebaseLogin, 'sign in')}
className="btn btn-outline btn-sm mx-auto mt-4 -ml-1 w-full rounded-md normal-case"
>
Sign in
</button>
)}
</div>
<CreateQuestionButton user={user} />
{user &&
mustWaitForFreeMarketStatus != 'loading' &&

View File

@ -6,7 +6,6 @@ import { Col } from './layout/col'
import { Row } from './layout/row'
import { TagsList } from './tags-list'
import { MAX_TAG_LENGTH } from 'common/contract'
import { track } from 'web/lib/service/analytics'
export function TagsInput(props: { contract: Contract; className?: string }) {
const { contract, className } = props
@ -25,7 +24,6 @@ export function TagsInput(props: { contract: Contract; className?: string }) {
})
setIsSubmitting(false)
setTagText('')
track('save tags')
}
return (
@ -55,25 +53,3 @@ export function TagsInput(props: { contract: Contract; className?: string }) {
</Col>
)
}
export function RevealableTagsInput(props: {
contract: Contract
className?: string
}) {
const { contract, className } = props
const [hidden, setHidden] = useState(true)
if (hidden)
return (
<div
className={clsx(
'cursor-pointer text-gray-500 hover:underline hover:decoration-indigo-400 hover:decoration-2',
className
)}
onClick={() => setHidden((hidden) => !hidden)}
>
Show tags
</div>
)
return <TagsInput className={clsx('pt-2', className)} contract={contract} />
}

View File

@ -27,6 +27,7 @@ import { getUserBets } from 'web/lib/firebase/bets'
import { FollowersButton, FollowingButton } from './following-button'
import { useFollows } from 'web/hooks/use-follows'
import { FollowButton } from './follow-button'
import { useRouter } from 'next/router'
export function UserLink(props: {
name: string
@ -47,13 +48,13 @@ export function UserLink(props: {
)
}
export const TAB_IDS = ['markets', 'comments', 'bets']
export const TAB_IDS = ['markets', 'comments', 'bets', 'groups']
const JUNE_1_2022 = new Date('2022-06-01T00:00:00.000Z').valueOf()
export function UserPage(props: {
user: User
currentUser?: User
defaultTabTitle?: 'markets' | 'comments' | 'bets'
defaultTabTitle?: string | undefined
}) {
const { user, currentUser, defaultTabTitle } = props
const isCurrentUser = user.id === currentUser?.id
@ -66,6 +67,7 @@ export function UserPage(props: {
const [commentsByContract, setCommentsByContract] = useState<
Map<Contract, Comment[]> | 'loading'
>('loading')
const router = useRouter()
useEffect(() => {
if (!user) return
@ -74,12 +76,15 @@ export function UserPage(props: {
getUserBets(user.id, { includeRedemptions: false }).then(setUsersBets)
}, [user])
// TODO: display comments on groups
useEffect(() => {
const uniqueContractIds = uniq(
usersComments.map((comment) => comment.contractId)
)
Promise.all(
uniqueContractIds.map((contractId) => getContractFromId(contractId))
uniqueContractIds.map(
(contractId) => contractId && getContractFromId(contractId)
)
).then((contracts) => {
const commentsByContract = new Map<Contract, Comment[]>()
contracts.forEach((contract) => {
@ -225,13 +230,17 @@ export function UserPage(props: {
{usersContracts !== 'loading' && commentsByContract != 'loading' ? (
<Tabs
className={'pb-2 pt-1 '}
defaultIndex={TAB_IDS.indexOf(defaultTabTitle || 'markets')}
defaultIndex={
defaultTabTitle ? TAB_IDS.indexOf(defaultTabTitle) : 0
}
onClick={(tabName) => {
const tabId = tabName.toLowerCase()
const subpath = tabId === 'markets' ? '' : '/' + tabId
const subpath = tabId === 'markets' ? '' : '?tab=' + tabId
// BUG: if you start on `/Bob/bets`, then click on Markets, use-query-and-sort-params
// rewrites the url incorrectly to `/Bob/bets` instead of `/Bob`
window.history.replaceState('', '', `/${user.username}${subpath}`)
router.push(`/${user.username}${subpath}`, undefined, {
shallow: true,
})
}}
tabs={[
{

View File

@ -1,7 +1,7 @@
import { useEffect, useState } from 'react'
import {
Comment,
listenForComments,
listenForCommentsOnContract,
listenForRecentComments,
} from 'web/lib/firebase/comments'
@ -9,7 +9,7 @@ export const useComments = (contractId: string) => {
const [comments, setComments] = useState<Comment[] | undefined>()
useEffect(() => {
if (contractId) return listenForComments(contractId, setComments)
if (contractId) return listenForCommentsOnContract(contractId, setComments)
}, [contractId])
return comments

View File

@ -9,7 +9,6 @@ import {
listenForInactiveContracts,
listenForNewContracts,
} from 'web/lib/firebase/contracts'
import { listenForTaggedContracts } from 'web/lib/firebase/folds'
export const useContracts = () => {
const [contracts, setContracts] = useState<Contract[] | undefined>()
@ -50,21 +49,6 @@ export const useInactiveContracts = () => {
return contracts
}
export const useTaggedContracts = (tags: string[] | undefined) => {
const [contracts, setContracts] = useState<Contract[] | undefined>(
tags && tags.length === 0 ? [] : undefined
)
const tagsKey = tags?.map((tag) => tag.toLowerCase()).join(',') ?? ''
useEffect(() => {
if (!tags || tags.length === 0) return
return listenForTaggedContracts(tags, setContracts)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [tagsKey])
return contracts
}
export const useHotContracts = () => {
const [hotContracts, setHotContracts] = useState<Contract[] | undefined>()

View File

@ -1,80 +0,0 @@
import { useEffect, useState } from 'react'
import { Fold } from 'common/fold'
import { User } from 'common/user'
import {
listenForFold,
listenForFolds,
listenForFoldsWithTags,
listenForFollow,
listenForFollowedFolds,
} from 'web/lib/firebase/folds'
export const useFold = (foldId: string | undefined) => {
const [fold, setFold] = useState<Fold | null | undefined>()
useEffect(() => {
if (foldId) return listenForFold(foldId, setFold)
}, [foldId])
return fold
}
export const useFolds = () => {
const [folds, setFolds] = useState<Fold[] | undefined>()
useEffect(() => {
return listenForFolds(setFolds)
}, [])
return folds
}
export const useFoldsWithTags = (tags: string[] | undefined) => {
const [folds, setFolds] = useState<Fold[] | undefined>()
const tagsKey = tags?.join(',')
useEffect(() => {
if (tags && tags.length > 0) return listenForFoldsWithTags(tags, setFolds)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [tagsKey])
return folds
}
export const useFollowingFold = (fold: Fold, user: User | null | undefined) => {
const [following, setFollowing] = useState<boolean | undefined>()
const foldId = fold?.id
const userId = user?.id
useEffect(() => {
if (userId) return listenForFollow(foldId, userId, setFollowing)
}, [foldId, userId])
return following
}
// Note: We cache followedFoldIds in localstorage to speed up the initial load
export const useFollowedFoldIds = (user: User | null | undefined) => {
const [followedFoldIds, setFollowedFoldIds] = useState<string[] | undefined>(
undefined
)
useEffect(() => {
if (user) {
const key = `followed-folds-${user.id}`
const followedFoldJson = localStorage.getItem(key)
if (followedFoldJson) {
setFollowedFoldIds(JSON.parse(followedFoldJson))
}
return listenForFollowedFolds(user.id, (foldIds) => {
setFollowedFoldIds(foldIds)
localStorage.setItem(key, JSON.stringify(foldIds))
})
}
}, [user])
return followedFoldIds
}

78
web/hooks/use-group.ts Normal file
View File

@ -0,0 +1,78 @@
import { useEffect, useState } from 'react'
import { Group } from 'common/group'
import { User } from 'common/user'
import {
listenForGroup,
listenForGroups,
listenForMemberGroups,
} from 'web/lib/firebase/groups'
import { getUser } from 'web/lib/firebase/users'
export const useGroup = (groupId: string | undefined) => {
const [group, setGroup] = useState<Group | null | undefined>()
useEffect(() => {
if (groupId) return listenForGroup(groupId, setGroup)
}, [groupId])
return group
}
export const useGroups = () => {
const [groups, setGroups] = useState<Group[] | undefined>()
useEffect(() => {
return listenForGroups(setGroups)
}, [])
return groups
}
export const useMemberGroups = (user: User | null | undefined) => {
const [memberGroups, setMemberGroups] = useState<Group[] | undefined>()
useEffect(() => {
if (user) return listenForMemberGroups(user.id, setMemberGroups)
}, [user])
return memberGroups
}
// Note: We cache member group ids in localstorage to speed up the initial load
export const useMemberGroupIds = (user: User | null | undefined) => {
const [memberGroupIds, setMemberGroupIds] = useState<string[] | undefined>(
undefined
)
useEffect(() => {
if (user) {
const key = `member-groups-${user.id}`
const memberGroupJson = localStorage.getItem(key)
if (memberGroupJson) {
setMemberGroupIds(JSON.parse(memberGroupJson))
}
return listenForMemberGroups(user.id, (Groups) => {
const groupIds = Groups.map((group) => group.id)
setMemberGroupIds(groupIds)
localStorage.setItem(key, JSON.stringify(groupIds))
})
}
}, [user])
return memberGroupIds
}
export function useMembers(group: Group) {
const [members, setMembers] = useState<User[]>([])
useEffect(() => {
const { memberIds, creatorId } = group
if (memberIds.length > 1)
// get users via their user ids:
Promise.all(
memberIds.filter((mId) => mId !== creatorId).map(getUser)
).then((users) => {
const members = users.filter((user) => user)
setMembers(members)
})
}, [group])
return members
}

View File

@ -15,6 +15,11 @@ export type Sort =
| 'resolve-date'
| 'last-updated'
export function checkAgainstQuery(query: string, corpus: string) {
const queryWords = query.toLowerCase().split(' ')
return queryWords.every((word) => corpus.toLowerCase().includes(word))
}
export function useInitialQueryAndSort(options?: {
defaultSort: Sort
shouldLoadFromStorage?: boolean

View File

@ -4,10 +4,12 @@ import { V2CloudFunction } from 'common/envs/prod'
export class APIError extends Error {
code: number
constructor(code: number, message: string) {
details?: string
constructor(code: number, message: string, details?: string) {
super(message)
this.code = code
this.name = 'APIError'
this.details = details
}
}
@ -28,7 +30,7 @@ export async function call(url: string, method: string, params: any) {
return await fetch(req).then(async (resp) => {
const json = (await resp.json()) as { [k: string]: any }
if (!resp.ok) {
throw new APIError(resp.status, json?.message)
throw new APIError(resp.status, json?.message, json?.details)
}
return json
})
@ -58,3 +60,7 @@ export function sellShares(params: any) {
export function sellBet(params: any) {
return call(getFunctionUrl('sellbet'), 'POST', params)
}
export function createGroup(params: any) {
return call(getFunctionUrl('creategroup'), 'POST', params)
}

View File

@ -20,7 +20,7 @@ export type { Comment }
export const MAX_COMMENT_LENGTH = 10000
export async function createComment(
export async function createCommentOnContract(
contractId: string,
text: string,
commenter: User,
@ -52,10 +52,39 @@ export async function createComment(
})
return await setDoc(ref, comment)
}
export async function createCommentOnGroup(
groupId: string,
text: string,
user: User,
replyToCommentId?: string
) {
const ref = doc(getCommentsOnGroupCollection(groupId))
const comment: Comment = removeUndefinedProps({
id: ref.id,
groupId,
userId: user.id,
text: text.slice(0, MAX_COMMENT_LENGTH),
createdTime: Date.now(),
userName: user.name,
userUsername: user.username,
userAvatarUrl: user.avatarUrl,
replyToCommentId: replyToCommentId,
})
track('group message', {
user,
commentId: ref.id,
groupId,
replyToCommentId: replyToCommentId,
})
return await setDoc(ref, comment)
}
function getCommentsCollection(contractId: string) {
return collection(db, 'contracts', contractId, 'comments')
}
function getCommentsOnGroupCollection(groupId: string) {
return collection(db, 'groups', groupId, 'comments')
}
export async function listAllComments(contractId: string) {
const comments = await getValues<Comment>(getCommentsCollection(contractId))
@ -63,7 +92,7 @@ export async function listAllComments(contractId: string) {
return comments
}
export function listenForComments(
export function listenForCommentsOnContract(
contractId: string,
setComments: (comments: Comment[]) => void
) {
@ -75,16 +104,17 @@ export function listenForComments(
}
)
}
// Return a map of betId -> comment
export function mapCommentsByBetId(comments: Comment[]) {
const map: Record<string, Comment> = {}
for (const comment of comments) {
if (comment.betId) {
map[comment.betId] = comment
export function listenForCommentsOnGroup(
groupId: string,
setComments: (comments: Comment[]) => void
) {
return listenForValues<Comment>(
getCommentsOnGroupCollection(groupId),
(comments) => {
comments.sort((c1, c2) => c1.createdTime - c2.createdTime)
setComments(comments)
}
}
return map
)
}
const DAY_IN_MS = 24 * 60 * 60 * 1000

View File

@ -166,6 +166,18 @@ export function listenForContracts(
return listenForValues<Contract>(q, setContracts)
}
export function listenForUserContracts(
creatorId: string,
setContracts: (contracts: Contract[]) => void
) {
const q = query(
contractCollection,
where('creatorId', '==', creatorId),
orderBy('createdTime', 'desc')
)
return listenForValues<Contract>(q, setContracts)
}
const activeContractsQuery = query(
contractCollection,
where('isResolved', '==', false),

View File

@ -1,5 +1,4 @@
import { httpsCallable } from 'firebase/functions'
import { Fold } from 'common/fold'
import { Txn } from 'common/txn'
import { User } from 'common/user'
import { randomString } from 'common/util/random'
@ -15,11 +14,6 @@ export const withdrawLiquidity = cloudFunction<
{ status: 'error' | 'success'; userShares: { [outcome: string]: number } }
>('withdrawLiquidity')
export const createFold = cloudFunction<
{ name: string; about: string; tags: string[] },
{ status: 'error' | 'success'; message?: string; fold?: Fold }
>('createFold')
export const transact = cloudFunction<
Omit<Txn, 'id' | 'createdTime'>,
{ status: 'error' | 'success'; message?: string; txn?: Txn }

View File

@ -1,220 +0,0 @@
import {
collection,
collectionGroup,
deleteDoc,
doc,
getDocs,
onSnapshot,
query,
setDoc,
updateDoc,
where,
} from 'firebase/firestore'
import { sortBy } from 'lodash'
import { Fold } from 'common/fold'
import { Contract, contractCollection } from './contracts'
import { db } from './init'
import { User } from './users'
import { getValue, getValues, listenForValue, listenForValues } from './utils'
const foldCollection = collection(db, 'folds')
export function foldPath(
fold: Fold,
subpath?: 'edit' | 'markets' | 'leaderboards'
) {
return `/fold/${fold.slug}${subpath ? `/${subpath}` : ''}`
}
export function updateFold(fold: Fold, updates: Partial<Fold>) {
return updateDoc(doc(foldCollection, fold.id), updates)
}
export function deleteFold(fold: Fold) {
return deleteDoc(doc(foldCollection, fold.id))
}
export async function listAllFolds() {
return getValues<Fold>(foldCollection)
}
export function listenForFolds(setFolds: (folds: Fold[]) => void) {
return listenForValues(foldCollection, setFolds)
}
export function getFold(foldId: string) {
return getValue<Fold>(doc(foldCollection, foldId))
}
export async function getFoldBySlug(slug: string) {
const q = query(foldCollection, where('slug', '==', slug))
const folds = await getValues<Fold>(q)
return folds.length === 0 ? null : folds[0]
}
function contractsByTagsQuery(tags: string[]) {
// TODO: if tags.length > 10, execute multiple parallel queries
const lowercaseTags = tags.map((tag) => tag.toLowerCase()).slice(0, 10)
return query(
contractCollection,
where('lowercaseTags', 'array-contains-any', lowercaseTags)
)
}
export async function getFoldContracts(fold: Fold) {
const {
tags,
contractIds,
excludedContractIds,
creatorIds,
excludedCreatorIds,
} = fold
const [tagsContracts, includedContracts] = await Promise.all([
tags.length > 0 ? getValues<Contract>(contractsByTagsQuery(tags)) : [],
// TODO: if contractIds.length > 10, execute multiple parallel queries
contractIds.length > 0
? getValues<Contract>(
query(contractCollection, where('id', 'in', contractIds))
)
: [],
])
const excludedContractsSet = new Set(excludedContractIds)
const creatorSet = creatorIds ? new Set(creatorIds) : undefined
const excludedCreatorSet = excludedCreatorIds
? new Set(excludedCreatorIds)
: undefined
const approvedContracts = tagsContracts.filter((contract) => {
const { id, creatorId } = contract
if (excludedContractsSet.has(id)) return false
if (creatorSet && !creatorSet.has(creatorId)) return false
if (excludedCreatorSet && excludedCreatorSet.has(creatorId)) return false
return true
})
return [...approvedContracts, ...includedContracts]
}
export function listenForTaggedContracts(
tags: string[],
setContracts: (contracts: Contract[]) => void
) {
return listenForValues<Contract>(contractsByTagsQuery(tags), setContracts)
}
export function listenForFold(
foldId: string,
setFold: (fold: Fold | null) => void
) {
return listenForValue(doc(foldCollection, foldId), setFold)
}
export function followFold(foldId: string, userId: string) {
const followDoc = doc(foldCollection, foldId, 'followers', userId)
return setDoc(followDoc, { userId })
}
export function unfollowFold(fold: Fold, user: User) {
const followDoc = doc(foldCollection, fold.id, 'followers', user.id)
return deleteDoc(followDoc)
}
export async function followFoldFromSlug(slug: string, userId: string) {
const snap = await getDocs(query(foldCollection, where('slug', '==', slug)))
if (snap.empty) return undefined
const foldDoc = snap.docs[0]
const followDoc = doc(foldDoc.ref, 'followers', userId)
return setDoc(followDoc, { userId })
}
export async function unfollowFoldFromSlug(slug: string, userId: string) {
const snap = await getDocs(query(foldCollection, where('slug', '==', slug)))
if (snap.empty) return undefined
const foldDoc = snap.docs[0]
const followDoc = doc(foldDoc.ref, 'followers', userId)
return deleteDoc(followDoc)
}
export function listenForFollow(
foldId: string,
userId: string,
setFollow: (following: boolean) => void
) {
const followDoc = doc(foldCollection, foldId, 'followers', userId)
return listenForValue(followDoc, (value) => {
setFollow(!!value)
})
}
export async function getFoldsByTags(tags: string[]) {
if (tags.length === 0) return []
// TODO: split into multiple queries if tags.length > 10.
const lowercaseTags = tags.map((tag) => tag.toLowerCase()).slice(0, 10)
const folds = await getValues<Fold>(
query(
foldCollection,
where('lowercaseTags', 'array-contains-any', lowercaseTags)
)
)
return sortBy(folds, (fold) => -1 * fold.followCount)
}
export function listenForFoldsWithTags(
tags: string[],
setFolds: (folds: Fold[]) => void
) {
// TODO: split into multiple queries if tags.length > 10.
const lowercaseTags = tags.map((tag) => tag.toLowerCase()).slice(0, 10)
const q = query(
foldCollection,
where('lowercaseTags', 'array-contains-any', lowercaseTags)
)
return listenForValues<Fold>(q, (folds) => {
const sorted = sortBy(folds, (fold) => -1 * fold.followCount)
setFolds(sorted)
})
}
export async function getFollowedFolds(userId: string) {
const snapshot = await getDocs(
query(collectionGroup(db, 'followers'), where('userId', '==', userId))
)
const foldIds = snapshot.docs.map(
(doc) => doc.ref.parent.parent?.id as string
)
return foldIds
}
export function listenForFollowedFolds(
userId: string,
setFoldIds: (foldIds: string[]) => void
) {
return onSnapshot(
query(collectionGroup(db, 'followers'), where('userId', '==', userId)),
(snapshot) => {
if (snapshot.metadata.fromCache) return
const foldIds = snapshot.docs.map(
(doc) => doc.ref.parent.parent?.id as string
)
setFoldIds(foldIds)
}
)
}

View File

@ -0,0 +1,84 @@
import {
collection,
deleteDoc,
doc,
query,
updateDoc,
where,
} from 'firebase/firestore'
import { sortBy } from 'lodash'
import { Group } from 'common/group'
import { getContractFromId } from './contracts'
import { db } from './init'
import { getValue, getValues, listenForValue, listenForValues } from './utils'
import { filterDefined } from 'common/util/array'
const groupCollection = collection(db, 'groups')
export function groupPath(
groupSlug: string,
subpath?: 'edit' | 'questions' | 'details' | 'discussion'
) {
return `/group/${groupSlug}${subpath ? `/${subpath}` : ''}`
}
export function updateGroup(group: Group, updates: Partial<Group>) {
return updateDoc(doc(groupCollection, group.id), updates)
}
export function deleteGroup(group: Group) {
return deleteDoc(doc(groupCollection, group.id))
}
export async function listAllGroups() {
return getValues<Group>(groupCollection)
}
export function listenForGroups(setGroups: (groups: Group[]) => void) {
return listenForValues(groupCollection, setGroups)
}
export function getGroup(groupId: string) {
return getValue<Group>(doc(groupCollection, groupId))
}
export async function getGroupBySlug(slug: string) {
const q = query(groupCollection, where('slug', '==', slug))
const groups = await getValues<Group>(q)
return groups.length === 0 ? null : groups[0]
}
export async function getGroupContracts(group: Group) {
const { contractIds } = group
const contracts =
filterDefined(
await Promise.all(
contractIds.map(async (contractId) => {
return await getContractFromId(contractId)
})
)
) ?? []
return [...contracts]
}
export function listenForGroup(
groupId: string,
setGroup: (group: Group | null) => void
) {
return listenForValue(doc(groupCollection, groupId), setGroup)
}
export function listenForMemberGroups(
userId: string,
setGroups: (groups: Group[]) => void
) {
const q = query(groupCollection, where('memberIds', 'array-contains', userId))
return listenForValues<Group>(q, (groups) => {
const sorted = sortBy(groups, [(group) => -group.mostRecentActivityTime])
setGroups(sorted)
})
}

View File

@ -1,5 +0,0 @@
import UserProfile from '.'
export default function UserBets() {
return <UserProfile tab="bets" />
}

View File

@ -1,5 +0,0 @@
import UserProfile from '.'
export default function UserBets() {
return <UserProfile tab="comments" />
}

View File

@ -7,13 +7,13 @@ import { useUser } from 'web/hooks/use-user'
import Custom404 from '../404'
import { useTracking } from 'web/hooks/use-tracking'
export default function UserProfile(props: {
tab?: 'markets' | 'comments' | 'bets'
}) {
export default function UserProfile() {
const router = useRouter()
const [user, setUser] = useState<User | null | 'loading'>('loading')
const { username } = router.query as { username: string }
const { username, tab } = router.query as {
username: string
tab?: string | undefined
}
useEffect(() => {
if (username) {
getUserByUsername(username).then(setUser)
@ -24,13 +24,13 @@ export default function UserProfile(props: {
useTracking('view user profile', { username })
if (user === 'loading') return <></>
if (user === 'loading') return <div />
return user ? (
<UserPage
user={user}
currentUser={currentUser || undefined}
defaultTabTitle={props.tab}
defaultTabTitle={tab}
/>
) : (
<Custom404 />

View File

@ -1,4 +1,4 @@
import router from 'next/router'
import router, { useRouter } from 'next/router'
import { useEffect, useState } from 'react'
import clsx from 'clsx'
import dayjs from 'dayjs'
@ -19,16 +19,22 @@ import {
import { formatMoney } from 'common/util/format'
import { useHasCreatedContractToday } from 'web/hooks/use-has-created-contract-today'
import { removeUndefinedProps } from 'common/util/object'
import { CATEGORIES } from 'common/categories'
import { ChoicesToggleGroup } from 'web/components/choices-toggle-group'
import { track } from 'web/lib/service/analytics'
import { getGroup, updateGroup } from 'web/lib/firebase/groups'
import { Group } from 'common/group'
import { useTracking } from 'web/hooks/use-tracking'
import { useWarnUnsavedChanges } from 'web/hooks/use-warn-unsaved-changes'
import { track } from 'web/lib/service/analytics'
import { GroupSelector } from 'web/components/groups/group-selector'
import { CATEGORIES } from 'common/categories'
export default function Create() {
const [question, setQuestion] = useState('')
// get query params:
const router = useRouter()
const { groupId } = router.query as { groupId: string }
useTracking('view create page')
if (!router.isReady) return <div />
return (
<Page>
@ -53,7 +59,7 @@ export default function Create() {
</div>
</form>
<Spacer h={6} />
<NewContract question={question} />
<NewContract question={question} groupId={groupId} />
</div>
</div>
</Page>
@ -61,8 +67,8 @@ export default function Create() {
}
// Allow user to create a new contract
export function NewContract(props: { question: string }) {
const { question } = props
export function NewContract(props: { question: string; groupId?: string }) {
const { question, groupId } = props
const creator = useUser()
useEffect(() => {
@ -74,11 +80,17 @@ export function NewContract(props: { question: string }) {
const [minString, setMinString] = useState('')
const [maxString, setMaxString] = useState('')
const [description, setDescription] = useState('')
const [category, setCategory] = useState<string>('')
// const [tagText, setTagText] = useState<string>(tag ?? '')
// const tags = parseWordsAsTags(tagText)
useEffect(() => {
if (groupId && creator)
getGroup(groupId).then((group) => {
if (group && group.memberIds.includes(creator.id)) {
setSelectedGroup(group)
setShowGroupSelector(false)
}
})
}, [creator, groupId])
const [ante, _setAnte] = useState(FIXED_ANTE)
const mustWaitForDailyFreeMarketStatus = useHasCreatedContractToday(creator)
@ -100,6 +112,11 @@ export function NewContract(props: { question: string }) {
const [closeHoursMinutes, setCloseHoursMinutes] = useState<string>('23:59')
const [marketInfoText, setMarketInfoText] = useState('')
const [isSubmitting, setIsSubmitting] = useState(false)
const [selectedGroup, setSelectedGroup] = useState<Group | undefined>(
undefined
)
const [showGroupSelector, setShowGroupSelector] = useState(true)
const [category, setCategory] = useState<string>('')
const closeTime = closeDate
? dayjs(`${closeDate}T${closeHoursMinutes}`).valueOf()
@ -145,7 +162,7 @@ export function NewContract(props: { question: string }) {
if (!creator || !isValid) return
setIsSubmitting(true)
// TODO: add contract id to the group contractIds
try {
const result = await createMarket(
removeUndefinedProps({
@ -155,18 +172,22 @@ export function NewContract(props: { question: string }) {
initialProb,
ante,
closeTime,
tags: category ? [category] : undefined,
min,
max,
groupId: selectedGroup?.id,
})
)
track('create market', {
slug: result.slug,
initialProb,
category,
selectedGroup: selectedGroup?.id,
isFree,
})
if (result && selectedGroup) {
await updateGroup(selectedGroup, {
contractIds: [...selectedGroup.contractIds, result.id],
})
}
await router.push(contractPath(result as Contract))
} catch (e) {
@ -245,17 +266,20 @@ export function NewContract(props: { question: string }) {
</div>
)}
<div className="form-control max-w-sm items-start">
<div className="form-control max-w-[265px] items-start">
<label className="label gap-2">
<span className="mb-1">Category</span>
</label>
<select
className="select select-bordered w-full max-w-xs"
className={clsx(
'select select-bordered w-full text-sm',
category === '' ? 'font-normal text-gray-500' : ''
)}
value={category}
onChange={(e) => setCategory(e.currentTarget.value ?? '')}
>
<option value={''}>(none)</option>
<option value={''}>None</option>
{Object.entries(CATEGORIES).map(([id, name]) => (
<option key={id} value={id}>
{name}
@ -264,6 +288,15 @@ export function NewContract(props: { question: string }) {
</select>
</div>
<div className={'mt-2'}>
<GroupSelector
selectedGroup={selectedGroup}
setSelectedGroup={setSelectedGroup}
creator={creator}
showSelector={showGroupSelector}
/>
</div>
<Spacer h={6} />
<div className="form-control mb-1 items-start">

View File

@ -1,330 +0,0 @@
import { flatten, take, partition, sortBy } from 'lodash'
import { Fold } from 'common/fold'
import { Comment } from 'common/comment'
import { Page } from 'web/components/page'
import { Title } from 'web/components/title'
import { Bet, listAllBets } from 'web/lib/firebase/bets'
import { Contract } from 'web/lib/firebase/contracts'
import {
foldPath,
getFoldBySlug,
getFoldContracts,
} from '../../../lib/firebase/folds'
import { TagsList } from '../../../components/tags-list'
import { Row } from '../../../components/layout/row'
import { UserLink } from '../../../components/user-page'
import { getUser, User } from '../../../lib/firebase/users'
import { Spacer } from '../../../components/layout/spacer'
import { Col } from '../../../components/layout/col'
import { useUser } from '../../../hooks/use-user'
import { useFold } from '../../../hooks/use-fold'
import { useRouter } from 'next/router'
import { scoreCreators, scoreTraders } from 'common/scoring'
import { Leaderboard } from 'web/components/leaderboard'
import { formatMoney } from 'common/util/format'
import { EditFoldButton } from 'web/components/folds/edit-fold-button'
import Custom404 from '../../404'
import { FollowFoldButton } from 'web/components/folds/follow-fold-button'
import { SEO } from 'web/components/SEO'
import { Linkify } from 'web/components/linkify'
import { fromPropz, usePropz } from 'web/hooks/use-propz'
import { findActiveContracts } from 'web/components/feed/find-active-contracts'
import { Tabs } from 'web/components/layout/tabs'
export const getStaticProps = fromPropz(getStaticPropz)
export async function getStaticPropz(props: { params: { slugs: string[] } }) {
const { slugs } = props.params
const fold = await getFoldBySlug(slugs[0])
const curatorPromise = fold ? getUser(fold.curatorId) : null
const contracts = fold ? await getFoldContracts(fold).catch((_) => []) : []
const bets = await Promise.all(
contracts.map((contract) => listAllBets(contract.id))
)
let activeContracts = findActiveContracts(contracts, [], flatten(bets), {})
const [resolved, unresolved] = partition(
activeContracts,
({ isResolved }) => isResolved
)
activeContracts = [...unresolved, ...resolved]
const creatorScores = scoreCreators(contracts)
const traderScores = scoreTraders(contracts, bets)
const [topCreators, topTraders] = await Promise.all([
toTopUsers(creatorScores),
toTopUsers(traderScores),
])
const curator = await curatorPromise
return {
props: {
fold,
curator,
contracts,
activeContracts,
traderScores,
topTraders,
creatorScores,
topCreators,
},
revalidate: 60, // regenerate after a minute
}
}
async function toTopUsers(userScores: { [userId: string]: number }) {
const topUserPairs = take(
sortBy(Object.entries(userScores), ([_, score]) => -1 * score),
10
).filter(([_, score]) => score >= 0.5)
const topUsers = await Promise.all(
topUserPairs.map(([userId]) => getUser(userId))
)
return topUsers.filter((user) => user)
}
export async function getStaticPaths() {
return { paths: [], fallback: 'blocking' }
}
const foldSubpages = [undefined, 'activity', 'markets', 'leaderboards'] as const
export default function FoldPage(props: {
fold: Fold | null
curator: User
contracts: Contract[]
activeContracts: Contract[]
activeContractBets: Bet[][]
activeContractComments: Comment[][]
traderScores: { [userId: string]: number }
topTraders: User[]
creatorScores: { [userId: string]: number }
topCreators: User[]
}) {
props = usePropz(props, getStaticPropz) ?? {
fold: null,
curator: null,
contracts: [],
activeContracts: [],
activeContractBets: [],
activeContractComments: [],
traderScores: {},
topTraders: [],
creatorScores: {},
topCreators: [],
}
const { curator, traderScores, topTraders, creatorScores, topCreators } =
props
const router = useRouter()
const { slugs } = router.query as { slugs: string[] }
const page = (slugs?.[1] ?? 'activity') as typeof foldSubpages[number]
const fold = useFold(props.fold?.id) ?? props.fold
const user = useUser()
const isCurator = user && fold && user.id === fold.curatorId
if (fold === null || !foldSubpages.includes(page) || slugs[2]) {
return <Custom404 />
}
const rightSidebar = (
<Col className="mt-6 gap-12">
<Row className="justify-end">
{isCurator ? (
<EditFoldButton className="ml-1" fold={fold} />
) : (
<FollowFoldButton className="ml-1" fold={fold} />
)}
</Row>
<FoldOverview fold={fold} curator={curator} />
<YourPerformance
traderScores={traderScores}
creatorScores={creatorScores}
user={user}
/>
</Col>
)
const leaderboardsTab = (
<Col className="gap-8 px-4 lg:flex-row">
<FoldLeaderboards
traderScores={traderScores}
creatorScores={creatorScores}
topTraders={topTraders}
topCreators={topCreators}
user={user}
/>
</Col>
)
return (
<Page rightSidebar={rightSidebar}>
<SEO
title={fold.name}
description={`Curated by ${curator.name}. ${fold.about}`}
url={foldPath(fold)}
/>
<div className="px-3 lg:px-1">
<Row className="mb-6 justify-between">
<Title className="!m-0" text={fold.name} />
</Row>
<Col className="mb-6 gap-2 text-gray-500 md:hidden">
<Row>
<div className="mr-1">Curated by</div>
<UserLink
className="text-neutral"
name={curator.name}
username={curator.username}
/>
</Row>
<Linkify text={fold.about ?? ''} />
</Col>
</div>
<Tabs
defaultIndex={page === 'leaderboards' ? 1 : 0}
tabs={[
{
title: 'Markets',
content: <div>This view is deprecated.</div>,
href: foldPath(fold, 'markets'),
},
{
title: 'Leaderboards',
content: leaderboardsTab,
href: foldPath(fold, 'leaderboards'),
},
]}
/>
</Page>
)
}
function FoldOverview(props: { fold: Fold; curator: User }) {
const { fold, curator } = props
const { about, tags } = fold
return (
<Col>
<div className="rounded-t bg-indigo-500 px-4 py-3 text-sm text-white">
About community
</div>
<Col className="gap-2 rounded-b bg-white p-4">
<Row>
<div className="mr-1 text-gray-500">Curated by</div>
<UserLink
className="text-neutral"
name={curator.name}
username={curator.username}
/>
</Row>
{about && (
<>
<Spacer h={2} />
<div className="text-gray-500">
<Linkify text={about} />
</div>
</>
)}
<div className="divider" />
<div className="mb-2 text-gray-500">
Includes markets matching any of these tags:
</div>
<TagsList tags={tags} noLabel />
</Col>
</Col>
)
}
function YourPerformance(props: {
traderScores: { [userId: string]: number }
creatorScores: { [userId: string]: number }
user: User | null | undefined
}) {
const { traderScores, creatorScores, user } = props
const yourTraderScore = user ? traderScores[user.id] : undefined
const yourCreatorScore = user ? creatorScores[user.id] : undefined
return user ? (
<Col>
<div className="rounded bg-indigo-500 px-4 py-3 text-sm text-white">
Your performance
</div>
<div className="bg-white p-2">
<table className="table-compact table w-full text-gray-500">
<tbody>
<tr>
<td>Trading profit</td>
<td>{formatMoney(yourTraderScore ?? 0)}</td>
</tr>
{yourCreatorScore && (
<tr>
<td>Created market vol</td>
<td>{formatMoney(yourCreatorScore)}</td>
</tr>
)}
</tbody>
</table>
</div>
</Col>
) : null
}
function FoldLeaderboards(props: {
traderScores: { [userId: string]: number }
creatorScores: { [userId: string]: number }
topTraders: User[]
topCreators: User[]
user: User | null | undefined
}) {
const { traderScores, creatorScores, topTraders, topCreators } = props
const topTraderScores = topTraders.map((user) => traderScores[user.id])
const topCreatorScores = topCreators.map((user) => creatorScores[user.id])
return (
<>
<Leaderboard
className="max-w-xl"
title="🏅 Top bettors"
users={topTraders}
columns={[
{
header: 'Profit',
renderCell: (user) =>
formatMoney(topTraderScores[topTraders.indexOf(user)]),
},
]}
/>
<Leaderboard
className="max-w-xl"
title="🏅 Top creators"
users={topCreators}
columns={[
{
header: 'Market vol',
renderCell: (user) =>
formatMoney(topCreatorScores[topCreators.indexOf(user)]),
},
]}
/>
</>
)
}

View File

@ -1,155 +0,0 @@
import { sortBy, debounce } from 'lodash'
import Link from 'next/link'
import { useEffect, useState } from 'react'
import { Fold } from 'common/fold'
import { CreateFoldButton } from 'web/components/folds/create-fold-button'
import { FollowFoldButton } from 'web/components/folds/follow-fold-button'
import { Col } from 'web/components/layout/col'
import { Row } from 'web/components/layout/row'
import { Page } from 'web/components/page'
import { TagsList } from 'web/components/tags-list'
import { Title } from 'web/components/title'
import { UserLink } from 'web/components/user-page'
import { useFolds, useFollowedFoldIds } from 'web/hooks/use-fold'
import { useUser } from 'web/hooks/use-user'
import { foldPath, listAllFolds } from 'web/lib/firebase/folds'
import { getUser, User } from 'web/lib/firebase/users'
export async function getStaticProps() {
const folds = await listAllFolds().catch((_) => [])
const curators = await Promise.all(
folds.map((fold) => getUser(fold.curatorId))
)
const curatorsDict = Object.fromEntries(
curators.map((curator) => [curator.id, curator])
)
return {
props: {
folds,
curatorsDict,
},
revalidate: 60, // regenerate after a minute
}
}
export default function Folds(props: {
folds: Fold[]
curatorsDict: { [k: string]: User }
}) {
const [curatorsDict, setCuratorsDict] = useState(props.curatorsDict)
const folds = useFolds() ?? props.folds
const user = useUser()
const followedFoldIds = useFollowedFoldIds(user) || []
useEffect(() => {
// Load User object for curator of new Folds.
const newFolds = folds.filter(({ curatorId }) => !curatorsDict[curatorId])
if (newFolds.length > 0) {
Promise.all(newFolds.map(({ curatorId }) => getUser(curatorId))).then(
(newUsers) => {
const newUsersDict = Object.fromEntries(
newUsers.map((user) => [user.id, user])
)
setCuratorsDict({ ...curatorsDict, ...newUsersDict })
}
)
}
}, [curatorsDict, folds])
const [query, setQuery] = useState('')
// Copied from contracts-list.tsx; extract if we copy this again
const queryWords = query.toLowerCase().split(' ')
function check(corpus: string) {
return queryWords.every((word) => corpus.toLowerCase().includes(word))
}
// List followed folds first, then folds with the highest follower count
const matches = sortBy(folds, [
(fold) => !followedFoldIds.includes(fold.id),
(fold) => -1 * fold.followCount,
]).filter(
(f) =>
check(f.name) ||
check(f.about || '') ||
check(curatorsDict[f.curatorId].username) ||
check(f.lowercaseTags.map((tag) => `#${tag}`).join(' '))
)
// Not strictly necessary, but makes the "hold delete" experience less laggy
const debouncedQuery = debounce(setQuery, 50)
return (
<Page>
<Col className="items-center">
<Col className="w-full max-w-xl">
<Col className="px-4 sm:px-0">
<Row className="items-center justify-between">
<Title text="Explore communities" />
{user && <CreateFoldButton />}
</Row>
<div className="mb-6 text-gray-500">
Communities on Manifold are centered around a collection of
markets. Follow a community to personalize your feed!
</div>
<input
type="text"
onChange={(e) => debouncedQuery(e.target.value)}
placeholder="Search communities"
className="input input-bordered mb-4 w-full"
/>
</Col>
<Col className="gap-4">
{matches.map((fold) => (
<FoldCard
key={fold.id}
fold={fold}
curator={curatorsDict[fold.curatorId]}
/>
))}
</Col>
</Col>
</Col>
</Page>
)
}
function FoldCard(props: { fold: Fold; curator: User | undefined }) {
const { fold, curator } = props
const tags = fold.tags.slice(1)
return (
<Col
key={fold.id}
className="relative gap-1 rounded-xl bg-white p-8 shadow-md hover:bg-gray-100"
>
<Link href={foldPath(fold)}>
<a className="absolute left-0 right-0 top-0 bottom-0" />
</Link>
<Row className="items-center justify-between gap-2">
<span className="text-xl">{fold.name}</span>
<FollowFoldButton className="z-10 mb-1" fold={fold} />
</Row>
<Row className="items-center gap-2 text-sm text-gray-500">
<div>{fold.followCount} followers</div>
<div></div>
<Row>
<div className="mr-1">Curated by</div>
<UserLink
className="text-neutral"
name={curator?.name ?? ''}
username={curator?.username ?? ''}
/>
</Row>
</Row>
<div className="text-sm text-gray-500">{fold.about}</div>
{tags.length > 0 && (
<TagsList className="mt-4" tags={tags} noLink noLabel />
)}
</Col>
)
}

View File

@ -0,0 +1,557 @@
import { take, sortBy, debounce } from 'lodash'
import { Group } from 'common/group'
import { Comment } from 'common/comment'
import { Page } from 'web/components/page'
import { Title } from 'web/components/title'
import { listAllBets } from 'web/lib/firebase/bets'
import { Contract, listenForUserContracts } from 'web/lib/firebase/contracts'
import {
groupPath,
getGroupBySlug,
getGroupContracts,
updateGroup,
} from 'web/lib/firebase/groups'
import { Row } from 'web/components/layout/row'
import { UserLink } from 'web/components/user-page'
import { getUser, User } from 'web/lib/firebase/users'
import { Spacer } from 'web/components/layout/spacer'
import { Col } from 'web/components/layout/col'
import { useUser } from 'web/hooks/use-user'
import { useGroup, useMembers } from 'web/hooks/use-group'
import { useRouter } from 'next/router'
import { scoreCreators, scoreTraders } from 'common/scoring'
import { Leaderboard } from 'web/components/leaderboard'
import { formatMoney } from 'common/util/format'
import { EditGroupButton } from 'web/components/groups/edit-group-button'
import Custom404 from '../../404'
import { SEO } from 'web/components/SEO'
import { Linkify } from 'web/components/linkify'
import { fromPropz, usePropz } from 'web/hooks/use-propz'
import { Tabs } from 'web/components/layout/tabs'
import { ContractsGrid } from 'web/components/contract/contracts-list'
import { CreateQuestionButton } from 'web/components/create-question-button'
import React, { useEffect, useState } from 'react'
import { Discussion } from 'web/components/groups/discussion'
import { listenForCommentsOnGroup } from 'web/lib/firebase/comments'
import { LoadingIndicator } from 'web/components/loading-indicator'
import { Modal } from 'web/components/layout/modal'
import { PlusIcon } from '@heroicons/react/outline'
import { checkAgainstQuery } from 'web/hooks/use-sort-and-query-params'
import { ChoicesToggleGroup } from 'web/components/choices-toggle-group'
import { toast } from 'react-hot-toast'
export const getStaticProps = fromPropz(getStaticPropz)
export async function getStaticPropz(props: { params: { slugs: string[] } }) {
const { slugs } = props.params
const group = await getGroupBySlug(slugs[0])
const creatorPromise = group ? getUser(group.creatorId) : null
const contracts = group ? await getGroupContracts(group).catch((_) => []) : []
const bets = await Promise.all(
contracts.map((contract: Contract) => listAllBets(contract.id))
)
const creatorScores = scoreCreators(contracts)
const traderScores = scoreTraders(contracts, bets)
const [topCreators, topTraders] = await Promise.all([
toTopUsers(creatorScores),
toTopUsers(traderScores),
])
const creator = await creatorPromise
return {
props: {
group,
creator,
traderScores,
topTraders,
creatorScores,
topCreators,
},
revalidate: 60, // regenerate after a minute
}
}
async function toTopUsers(userScores: { [userId: string]: number }) {
const topUserPairs = take(
sortBy(Object.entries(userScores), ([_, score]) => -1 * score),
10
).filter(([_, score]) => score >= 0.5)
const topUsers = await Promise.all(
topUserPairs.map(([userId]) => getUser(userId))
)
return topUsers.filter((user) => user)
}
export async function getStaticPaths() {
return { paths: [], fallback: 'blocking' }
}
const groupSubpages = [undefined, 'discussion', 'questions', 'details'] as const
export default function GroupPage(props: {
group: Group | null
creator: User
traderScores: { [userId: string]: number }
topTraders: User[]
creatorScores: { [userId: string]: number }
topCreators: User[]
}) {
props = usePropz(props, getStaticPropz) ?? {
group: null,
creator: null,
traderScores: {},
topTraders: [],
creatorScores: {},
topCreators: [],
}
const { creator, traderScores, topTraders, creatorScores, topCreators } =
props
const router = useRouter()
const { slugs } = router.query as { slugs: string[] }
const page = (slugs?.[1] ?? 'discussion') as typeof groupSubpages[number]
const group = useGroup(props.group?.id) ?? props.group
const [messages, setMessages] = useState<Comment[] | undefined>(undefined)
const [contracts, setContracts] = useState<Contract[] | undefined>(undefined)
useEffect(() => {
if (group) listenForCommentsOnGroup(group.id, setMessages)
}, [group])
useEffect(() => {
if (group)
getGroupContracts(group).then((contracts) => setContracts(contracts))
}, [group])
const user = useUser()
if (group === null || !groupSubpages.includes(page) || slugs[2]) {
return <Custom404 />
}
const { memberIds } = group
const isCreator = user && group && user.id === group.creatorId
const isMember = user && memberIds.includes(user.id)
const rightSidebar = (
<Col className="mt-6 hidden xl:block">
<GroupOverview
group={group}
creator={creator}
isCreator={!!isCreator}
user={user}
/>
<YourPerformance
traderScores={traderScores}
creatorScores={creatorScores}
user={user}
/>
{contracts && (
<div className={'mt-2'}>
<div className={'my-2 text-lg text-indigo-700'}>Recent Questions</div>
<ContractsGrid
contracts={contracts
.sort((a, b) => b.createdTime - a.createdTime)
.slice(0, 3)}
hasMore={false}
loadMore={() => {}}
overrideGridClassName={'grid w-full grid-cols-1 gap-4'}
/>
</div>
)}
</Col>
)
const leaderboardsTab = (
<Col className="mt-4 gap-8 px-4 md:flex-row">
<GroupLeaderboards
traderScores={traderScores}
creatorScores={creatorScores}
topTraders={topTraders}
topCreators={topCreators}
user={user}
/>
</Col>
)
return (
<Page rightSidebar={rightSidebar}>
<SEO
title={group.name}
description={`Created by ${creator.name}. ${group.about}`}
url={groupPath(group.slug)}
/>
<div className="px-3 lg:px-1">
<Row className={' items-center justify-between gap-4 '}>
<div className={'mb-1'}>
<Title className={'line-clamp-2'} text={group.name} />
<span className={'text-gray-700'}>{group.about}</span>
</div>
{isMember && (
<CreateQuestionButton
user={user}
overrideText={'Add a new question'}
className={'w-48 flex-shrink-0'}
query={`?groupId=${group.id}`}
/>
)}
{!isMember && group.anyoneCanJoin && (
<JoinGroupButton group={group} user={user} />
)}
</Row>
</div>
<Tabs
defaultIndex={page === 'details' ? 2 : page === 'questions' ? 1 : 0}
tabs={[
{
title: 'Discussion',
content: messages ? (
<Discussion messages={messages} user={user} group={group} />
) : (
<LoadingIndicator />
),
href: groupPath(group.slug, 'discussion'),
},
{
title: 'Questions',
content: (
<div className={'mt-2'}>
{contracts ? (
contracts.length > 0 ? (
<ContractsGrid
contracts={contracts}
hasMore={false}
loadMore={() => {}}
/>
) : (
<div className="p-2 text-gray-500">
No questions yet. 🦗... Why not add one?
</div>
)
) : (
<LoadingIndicator />
)}
{isMember && <AddContractButton group={group} user={user} />}
</div>
),
href: groupPath(group.slug, 'questions'),
},
{
title: 'Details',
content: (
<>
<div className={'xl:hidden'}>
<GroupOverview
group={group}
creator={creator}
isCreator={!!isCreator}
user={user}
/>
<YourPerformance
traderScores={traderScores}
creatorScores={creatorScores}
user={user}
/>
</div>
{leaderboardsTab}
</>
),
href: groupPath(group.slug, 'details'),
},
]}
/>
</Page>
)
}
function GroupOverview(props: {
group: Group
creator: User
user: User | null | undefined
isCreator: boolean
}) {
const { group, creator, isCreator, user } = props
const { about } = group
const anyoneCanJoinChoices: { [key: string]: string } = {
Closed: 'false',
Open: 'true',
}
const [anyoneCanJoin, setAnyoneCanJoin] = useState(group.anyoneCanJoin)
function updateAnyoneCanJoin(newVal: boolean) {
if (group.anyoneCanJoin == newVal || !isCreator) return
setAnyoneCanJoin(newVal)
toast.promise(updateGroup(group, { ...group, anyoneCanJoin: newVal }), {
loading: 'Updating group...',
success: 'Updated group!',
error: "Couldn't update group",
})
}
return (
<Col>
<Row className="items-center justify-end rounded-t bg-indigo-500 px-4 py-3 text-sm text-white">
<Row className="flex-1 justify-start">About group</Row>
{isCreator && <EditGroupButton className={'ml-1'} group={group} />}
</Row>
<Col className="gap-2 rounded-b bg-white p-4">
<Row>
<div className="mr-1 text-gray-500">Created by</div>
<UserLink
className="text-neutral"
name={creator.name}
username={creator.username}
/>
</Row>
<GroupMembersList group={group} />
<Row className={'items-center gap-1'}>
<span className={'text-gray-500'}>Membership</span>
{user && user.id === creator.id ? (
<ChoicesToggleGroup
currentChoice={anyoneCanJoin.toString()}
choicesMap={anyoneCanJoinChoices}
setChoice={(choice) =>
updateAnyoneCanJoin(choice.toString() === 'true')
}
toggleClassName={'h-10'}
className={'ml-2'}
/>
) : (
<span className={'text-gray-700'}>
{anyoneCanJoin ? 'Open' : 'Closed'}
</span>
)}
</Row>
{about && (
<>
<Spacer h={2} />
<div className="text-gray-500">
<Linkify text={about} />
</div>
</>
)}
</Col>
</Col>
)
}
export function GroupMembersList(props: { group: Group }) {
const { group } = props
const members = useMembers(group)
const maxMambersToShow = 5
if (group.memberIds.length === 1) return <div />
return (
<div>
<div>
<div className="text-neutral flex flex-wrap gap-1">
<span className={'text-gray-500'}>Other members</span>
{members.slice(0, maxMambersToShow).map((member, i) => (
<div key={member.id} className={'flex-shrink'}>
<UserLink name={member.name} username={member.username} />
{members.length > 1 && i !== members.length - 1 && <span>,</span>}
</div>
))}
{members.length > maxMambersToShow && (
<span> & {members.length - maxMambersToShow} more</span>
)}
</div>
</div>
</div>
)
}
function YourPerformance(props: {
traderScores: { [userId: string]: number }
creatorScores: { [userId: string]: number }
user: User | null | undefined
}) {
const { traderScores, creatorScores, user } = props
const yourTraderScore = user ? traderScores[user.id] : undefined
const yourCreatorScore = user ? creatorScores[user.id] : undefined
return user ? (
<Col>
<div className="rounded bg-indigo-500 px-4 py-3 text-sm text-white">
Your performance
</div>
<div className="bg-white p-2">
<table className="table-compact table w-full text-gray-500">
<tbody>
<tr>
<td>Total profit</td>
<td>{formatMoney(yourTraderScore ?? 0)}</td>
</tr>
{yourCreatorScore && (
<tr>
<td>Total created pool</td>
<td>{formatMoney(yourCreatorScore)}</td>
</tr>
)}
</tbody>
</table>
</div>
</Col>
) : null
}
function GroupLeaderboards(props: {
traderScores: { [userId: string]: number }
creatorScores: { [userId: string]: number }
topTraders: User[]
topCreators: User[]
user: User | null | undefined
}) {
const { traderScores, creatorScores, topTraders, topCreators } = props
const topTraderScores = topTraders.map((user) => traderScores[user.id])
const topCreatorScores = topCreators.map((user) => creatorScores[user.id])
return (
<>
<Leaderboard
className="max-w-xl"
title="🏅 Top bettors"
users={topTraders}
columns={[
{
header: 'Profit',
renderCell: (user) =>
formatMoney(topTraderScores[topTraders.indexOf(user)]),
},
]}
/>
<Leaderboard
className="max-w-xl"
title="🏅 Top creators"
users={topCreators}
columns={[
{
header: 'Market volume',
renderCell: (user) =>
formatMoney(topCreatorScores[topCreators.indexOf(user)]),
},
]}
/>
</>
)
}
function AddContractButton(props: { group: Group; user: User }) {
const { group, user } = props
const [open, setOpen] = useState(false)
const [contracts, setContracts] = useState<Contract[] | undefined>(undefined)
const [query, setQuery] = useState('')
useEffect(() => {
return listenForUserContracts(user.id, (contracts) => {
setContracts(
contracts.filter(
(c) =>
!group.contractIds.includes(c.id) &&
// TODO: It'll be easy to allow questions to be in multiple groups as long as we
// have the on-update-group function update the newly added contract's groupDetails (via contractIds)
!c.groupDetails
)
)
})
}, [group.contractIds, user.id])
async function addContractToGroup(contract: Contract) {
await updateGroup(group, {
...group,
contractIds: [...group.contractIds, contract.id],
})
setOpen(false)
}
// TODO use find-active-contracts to sort by?
const matches = sortBy(contracts, [
(contract) => -1 * contract.createdTime,
]).filter(
(c) =>
checkAgainstQuery(query, c.question) ||
checkAgainstQuery(query, c.description) ||
checkAgainstQuery(query, c.tags.flat().join(' '))
)
const debouncedQuery = debounce(setQuery, 50)
return (
<>
<Modal open={open} setOpen={setOpen}>
<Col className={'max-h-[60vh] w-full gap-4 rounded-md bg-white p-8'}>
<div className={'text-lg text-indigo-700'}>
Add a question to your group
</div>
<input
type="text"
onChange={(e) => debouncedQuery(e.target.value)}
placeholder="Search your questions"
className="input input-bordered mb-4 w-full"
/>
<div className={'overflow-y-scroll'}>
{contracts ? (
<ContractsGrid
contracts={matches}
loadMore={() => {}}
hasMore={false}
onContractClick={(contract) => {
addContractToGroup(contract)
}}
overrideGridClassName={'flex grid-cols-1 flex-col gap-3 p-1'}
hideQuickBet={true}
/>
) : (
<LoadingIndicator />
)}
</div>
</Col>
</Modal>
<Row className={'items-center justify-center'}>
<button
className={
'btn btn-sm btn-outline cursor-pointer gap-2 whitespace-nowrap text-sm normal-case'
}
onClick={() => setOpen(true)}
>
<PlusIcon className="mr-1 h-5 w-5" />
Add old questions to this group
</button>
</Row>
</>
)
}
function JoinGroupButton(props: {
group: Group
user: User | null | undefined
}) {
const { group, user } = props
function joinGroup() {
if (user && !group.memberIds.includes(user.id)) {
toast.promise(
updateGroup(group, {
...group,
memberIds: [...group.memberIds, user.id],
}),
{
loading: 'Joining group...',
success: 'Joined group!',
error: "Couldn't join group",
}
)
}
}
return (
<div>
<button onClick={joinGroup} className={'btn-md btn-outline btn '}>
Join Group
</button>
</div>
)
}

204
web/pages/groups.tsx Normal file
View File

@ -0,0 +1,204 @@
import { sortBy, debounce } from 'lodash'
import Link from 'next/link'
import { useEffect, useState } from 'react'
import { Group } from 'common/group'
import { CreateGroupButton } from 'web/components/groups/create-group-button'
import { Col } from 'web/components/layout/col'
import { Row } from 'web/components/layout/row'
import { Page } from 'web/components/page'
import { Title } from 'web/components/title'
import { UserLink } from 'web/components/user-page'
import { useGroups, useMemberGroupIds } from 'web/hooks/use-group'
import { useUser } from 'web/hooks/use-user'
import { groupPath, listAllGroups } from 'web/lib/firebase/groups'
import { getUser, User } from 'web/lib/firebase/users'
import { Tabs } from 'web/components/layout/tabs'
import { GroupMembersList } from 'web/pages/group/[...slugs]'
import { checkAgainstQuery } from 'web/hooks/use-sort-and-query-params'
export async function getStaticProps() {
const groups = await listAllGroups().catch((_) => [])
const creators = await Promise.all(
groups.map((group) => getUser(group.creatorId))
)
const creatorsDict = Object.fromEntries(
creators.map((creator) => [creator.id, creator])
)
return {
props: {
groups: groups,
creatorsDict,
},
revalidate: 60, // regenerate after a minute
}
}
export default function Groups(props: {
groups: Group[]
creatorsDict: { [k: string]: User }
}) {
const [creatorsDict, setCreatorsDict] = useState(props.creatorsDict)
const groups = useGroups() ?? props.groups
const user = useUser()
const memberGroupIds = useMemberGroupIds(user) || []
useEffect(() => {
// Load User object for creator of new Groups.
const newGroups = groups.filter(({ creatorId }) => !creatorsDict[creatorId])
if (newGroups.length > 0) {
Promise.all(newGroups.map(({ creatorId }) => getUser(creatorId))).then(
(newUsers) => {
const newUsersDict = Object.fromEntries(
newUsers.map((user) => [user.id, user])
)
setCreatorsDict({ ...creatorsDict, ...newUsersDict })
}
)
}
}, [creatorsDict, groups])
const [query, setQuery] = useState('')
// List groups with the highest question count, then highest member count
// TODO use find-active-contracts to sort by?
const matches = sortBy(groups, [
(group) => -1 * group.contractIds.length,
(group) => -1 * group.memberIds.length,
]).filter(
(g) =>
checkAgainstQuery(query, g.name) ||
checkAgainstQuery(query, g.about || '') ||
checkAgainstQuery(query, creatorsDict[g.creatorId].username)
)
const matchesOrderedByRecentActivity = sortBy(groups, [
(group) => -1 * group.mostRecentActivityTime,
]).filter(
(g) =>
checkAgainstQuery(query, g.name) ||
checkAgainstQuery(query, g.about || '') ||
checkAgainstQuery(query, creatorsDict[g.creatorId].username)
)
// Not strictly necessary, but makes the "hold delete" experience less laggy
const debouncedQuery = debounce(setQuery, 50)
return (
<Page>
<Col className="items-center">
<Col className="w-full max-w-xl">
<Col className="px-4 sm:px-0">
<Row className="items-center justify-between">
<Title text="Explore groups" />
{user && (
<CreateGroupButton user={user} goToGroupOnSubmit={true} />
)}
</Row>
<div className="mb-6 text-gray-500">
Discuss and compete on questions with a group of friends.
</div>
<Tabs
tabs={[
...(user
? [
{
title: 'My Groups',
content: (
<Col>
<input
type="text"
onChange={(e) => debouncedQuery(e.target.value)}
placeholder="Search your groups"
className="input input-bordered mb-4 w-full"
/>
<Col className="gap-4">
{matchesOrderedByRecentActivity
.filter((match) =>
memberGroupIds.includes(match.id)
)
.map((group) => (
<GroupCard
key={group.id}
group={group}
creator={creatorsDict[group.creatorId]}
/>
))}
</Col>
</Col>
),
},
]
: []),
{
title: 'All',
content: (
<Col>
<input
type="text"
onChange={(e) => debouncedQuery(e.target.value)}
placeholder="Search groups"
className="input input-bordered mb-4 w-full"
/>
<Col className="gap-4">
{matches.map((group) => (
<GroupCard
key={group.id}
group={group}
creator={creatorsDict[group.creatorId]}
/>
))}
</Col>
</Col>
),
},
]}
/>
</Col>
</Col>
</Col>
</Page>
)
}
export function GroupCard(props: { group: Group; creator: User | undefined }) {
const { group, creator } = props
return (
<Col
key={group.id}
className="relative gap-1 rounded-xl bg-white p-8 shadow-md hover:bg-gray-100"
>
<Link href={groupPath(group.slug)}>
<a className="absolute left-0 right-0 top-0 bottom-0" />
</Link>
<Row className="items-center justify-between gap-2">
<span className="text-xl">{group.name}</span>
</Row>
<div className="flex flex-col items-start justify-start gap-2 text-sm text-gray-500 ">
<Row>
{group.contractIds.length} questions
<div className={'mx-2'}></div>
<div className="mr-1">Created by</div>
<UserLink
className="text-neutral"
name={creator?.name ?? ''}
username={creator?.username ?? ''}
/>
</Row>
{group.memberIds.length > 1 && (
<Row>
<GroupMembersList group={group} />
</Row>
)}
</div>
<div className="text-sm text-gray-500">{group.about}</div>
</Col>
)
}

View File

@ -43,6 +43,7 @@ import { getContractFromId } from 'web/lib/firebase/contracts'
import { CheckIcon, XIcon } from '@heroicons/react/outline'
import toast from 'react-hot-toast'
import { formatMoney } from 'common/util/format'
import { groupPath } from 'web/lib/firebase/groups'
export default function Notifications() {
const user = useUser()
@ -530,6 +531,8 @@ function NotificationItem(props: {
sourceContractTitle,
sourceContractCreatorUsername,
sourceContractSlug,
sourceSlug,
sourceTitle,
} = notification
const [defaultNotificationText, setDefaultNotificationText] =
@ -595,6 +598,7 @@ function NotificationItem(props: {
function getSourceUrl() {
if (sourceType === 'follow') return `/${sourceUserUsername}`
if (sourceType === 'group' && sourceSlug) return `${groupPath(sourceSlug)}`
if (sourceContractCreatorUsername && sourceContractSlug)
return `/${sourceContractCreatorUsername}/${sourceContractSlug}#${getSourceIdForLinkComponent(
sourceId ?? ''
@ -722,13 +726,15 @@ function NotificationItem(props: {
href={
sourceContractCreatorUsername
? `/${sourceContractCreatorUsername}/${sourceContractSlug}`
: sourceType === 'group' && sourceSlug
? `${groupPath(sourceSlug)}`
: `/${contract?.creatorUsername}/${contract?.slug}`
}
className={
'ml-1 font-bold hover:underline hover:decoration-indigo-400 hover:decoration-2'
}
>
{contract?.question || sourceContractTitle}
{contract?.question || sourceContractTitle || sourceTitle}
</a>
</div>
)}
@ -736,8 +742,8 @@ function NotificationItem(props: {
</div>
{sourceId && sourceContractSlug && sourceContractCreatorUsername ? (
<CopyLinkDateTimeComponent
contractCreatorUsername={sourceContractCreatorUsername}
contractSlug={sourceContractSlug}
prefix={sourceContractCreatorUsername}
slug={sourceContractSlug}
createdTime={createdTime}
elementId={getSourceIdForLinkComponent(sourceId)}
className={'-mx-1 inline-flex sm:inline-block'}
@ -877,6 +883,9 @@ function getReasonForShowingNotification(
case 'liquidity':
reasonText = 'added liquidity to your question'
break
case 'group':
reasonText = 'added you to the group'
break
default:
reasonText = ''
}