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(() => {
|
||||
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={commentText}
|
||||
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={
|
||||
isReply
|
||||
? 'Write a reply... '
|
||||
: enterToSubmit
|
||||
? 'Send a message'
|
||||
: 'Write a comment...'
|
||||
}
|
||||
autoFocus={false}
|
||||
maxLength={MAX_COMMENT_LENGTH}
|
||||
disabled={isSubmitting}
|
||||
onKeyDown={(e) => {
|
||||
if (
|
||||
(enterToSubmit && e.key === 'Enter' && !e.shiftKey) ||
|
||||
(e.key === 'Enter' && (e.ctrlKey || e.metaKey))
|
||||
) {
|
||||
e.preventDefault()
|
||||
submitComment(presetId)
|
||||
e.currentTarget.blur()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<Col className={clsx('relative justify-end')}>
|
||||
{user && !isSubmitting && (
|
||||
<button
|
||||
className={clsx(
|
||||
'btn btn-ghost btn-sm absolute right-2 flex-row pl-2 capitalize',
|
||||
isReply ? ' bottom-4' : ' bottom-2',
|
||||
!commentText && 'pointer-events-none text-gray-500'
|
||||
)}
|
||||
onClick={() => {
|
||||
submitComment(presetId)
|
||||
}}
|
||||
>
|
||||
<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(presetId)}
|
||||
>
|
||||
Sign in to comment
|
||||
</button>
|
||||
)}
|
||||
</Row>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function TruncatedComment(props: {
|
||||
comment: string
|
||||
moreHref: string
|
||||
|
|
|
@ -39,8 +39,10 @@ export function findActiveContracts(
|
|||
|
||||
// Add every contract that had a recent comment, too
|
||||
for (const comment of recentComments) {
|
||||
const contract = contractsById.get(comment.contractId)
|
||||
if (contract) record(contract.id, comment.createdTime)
|
||||
if (comment.contractId) {
|
||||
const contract = contractsById.get(comment.contractId)
|
||||
if (contract) record(contract.id, comment.createdTime)
|
||||
}
|
||||
}
|
||||
|
||||
// Add contracts by last bet time.
|
||||
|
|
118
web/components/filter-select-users.tsx
Normal file
118
web/components/filter-select-users.tsx
Normal 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>
|
||||
)
|
||||
}
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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} />
|
||||
</>
|
||||
)
|
||||
}
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
140
web/components/groups/create-group-button.tsx
Normal file
140
web/components/groups/create-group-button.tsx
Normal 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>
|
||||
)
|
||||
}
|
185
web/components/groups/discussion.tsx
Normal file
185
web/components/groups/discussion.tsx
Normal 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>
|
||||
)
|
||||
})
|
|
@ -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>
|
||||
)
|
||||
}
|
139
web/components/groups/group-selector.tsx
Normal file
139
web/components/groups/group-selector.tsx
Normal 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>
|
||||
)
|
||||
}
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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
|
||||
? 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 navigationOptions = !user
|
||||
? signedOutNavigation
|
||||
: getNavigation(user?.username || 'error')
|
||||
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' &&
|
||||
|
|
|
@ -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} />
|
||||
}
|
||||
|
|
|
@ -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={[
|
||||
{
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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>()
|
||||
|
||||
|
|
|
@ -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
78
web/hooks/use-group.ts
Normal 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
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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 }
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
)
|
||||
}
|
84
web/lib/firebase/groups.ts
Normal file
84
web/lib/firebase/groups.ts
Normal 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)
|
||||
})
|
||||
}
|
|
@ -1,5 +0,0 @@
|
|||
import UserProfile from '.'
|
||||
|
||||
export default function UserBets() {
|
||||
return <UserProfile tab="bets" />
|
||||
}
|
|
@ -1,5 +0,0 @@
|
|||
import UserProfile from '.'
|
||||
|
||||
export default function UserBets() {
|
||||
return <UserProfile tab="comments" />
|
||||
}
|
|
@ -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 />
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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)]),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
|
@ -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>
|
||||
)
|
||||
}
|
557
web/pages/group/[...slugs]/index.tsx
Normal file
557
web/pages/group/[...slugs]/index.tsx
Normal 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
204
web/pages/groups.tsx
Normal 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>
|
||||
)
|
||||
}
|
|
@ -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 = ''
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue
Block a user