Groups (#510)
* 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:
parent
67d0a6c0c2
commit
3b3717d307
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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',
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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
21
common/group.ts
Normal 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
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
}
|
87
functions/src/create-group.ts
Normal file
87
functions/src/create-group.ts
Normal 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)
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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'
|
||||
|
|
30
functions/src/on-create-group.ts
Normal file
30
functions/src/on-create-group.ts
Normal 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
|
||||
)
|
||||
}
|
||||
})
|
|
@ -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()))
|
||||
})
|
|
@ -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 })
|
||||
})
|
20
functions/src/on-update-group.ts
Normal file
20
functions/src/on-update-group.ts
Normal 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() })
|
||||
})
|
|
@ -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())
|
||||
}
|
|
@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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} />
|
||||
)}
|
||||
|
|
|
@ -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>
|
||||
|
|
34
web/components/create-question-button.tsx
Normal file
34
web/components/create-question-button.tsx
Normal 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>
|
||||
)
|
||||
}
|
|
@ -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'}
|
||||
|
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
@ -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(() => {
|
||||