* 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,69 +444,17 @@ export function CommentInput(props: {
</>
)}
</div>
<Row className="gap-1.5 text-gray-700">
<Textarea
ref={setRef}
value={comment}
onChange={(e) => setComment(e.target.value)}
className={clsx(
'textarea textarea-bordered w-full resize-none'
)}
// Make room for floating submit button.
style={{ paddingRight: 48 }}
placeholder={
parentCommentId || parentAnswerOutcome
? 'Write a reply... '
: 'Write a comment...'
}
autoFocus={false}
maxLength={MAX_COMMENT_LENGTH}
disabled={isSubmitting}
onKeyDown={(e) => {
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
e.preventDefault()
submitComment(id)
e.currentTarget.blur()
}
}}
/>
<Col className={clsx('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'
)}
onClick={() => {
submitComment(id)
}}
>
<PaperAirplaneIcon
className={'m-0 min-w-[22px] rotate-90 p-0 '}
height={25}
/>
</button>
)}
{isSubmitting && (
<LoadingIndicator spinnerClassName={'border-gray-500'} />
)}
</Col>
</Row>
<Row>
{!user && (
<button
className={'btn btn-outline btn-sm mt-2 normal-case'}
onClick={() => submitComment(id)}
>
Sign in to comment
</button>
)}
</Row>
<CommentInputTextArea
commentText={comment}
setComment={setComment}
isReply={!!parentCommentId || !!parentAnswerOutcome}
replyToUsername={replyToUsername ?? ''}
user={user}
submitComment={submitComment}
isSubmitting={isSubmitting}
setRef={setRef}
presetId={id}
/>
</div>
</div>
</Row>
@ -512,6 +462,107 @@ export function CommentInput(props: {
)
}
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(() => {