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.
|
// They're uniquely identified by the pair contractId/betId.
|
||||||
export type Comment = {
|
export type Comment = {
|
||||||
id: string
|
id: string
|
||||||
contractId: string
|
contractId?: string
|
||||||
|
groupId?: string
|
||||||
betId?: string
|
betId?: string
|
||||||
answerOutcome?: string
|
answerOutcome?: string
|
||||||
replyToCommentId?: string
|
replyToCommentId?: string
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
import { Answer } from './answer'
|
import { Answer } from './answer'
|
||||||
import { Fees } from './fees'
|
import { Fees } from './fees'
|
||||||
|
import { GroupDetails } from 'common/group'
|
||||||
|
|
||||||
export type AnyMechanism = DPM | CPMM
|
export type AnyMechanism = DPM | CPMM
|
||||||
export type AnyOutcomeType = Binary | FreeResponse | Numeric
|
export type AnyOutcomeType = Binary | FreeResponse | Numeric
|
||||||
|
@ -24,6 +25,8 @@ export type Contract<T extends AnyContractType = AnyContractType> = {
|
||||||
lowercaseTags: string[]
|
lowercaseTags: string[]
|
||||||
visibility: 'public' | 'unlisted'
|
visibility: 'public' | 'unlisted'
|
||||||
|
|
||||||
|
groupDetails?: GroupDetails[] // Starting with one group per contract
|
||||||
|
|
||||||
createdTime: number // Milliseconds since epoch
|
createdTime: number // Milliseconds since epoch
|
||||||
lastUpdatedTime?: number // Updated on new bet or comment
|
lastUpdatedTime?: number // Updated on new bet or comment
|
||||||
lastBetTime?: number
|
lastBetTime?: number
|
||||||
|
|
|
@ -17,6 +17,7 @@ export const DEV_CONFIG: EnvConfig = {
|
||||||
sellshares: 'https://sellshares-w3txbmd3ba-uc.a.run.app',
|
sellshares: 'https://sellshares-w3txbmd3ba-uc.a.run.app',
|
||||||
sellbet: 'https://sellbet-w3txbmd3ba-uc.a.run.app',
|
sellbet: 'https://sellbet-w3txbmd3ba-uc.a.run.app',
|
||||||
createmarket: 'https://createmarket-w3txbmd3ba-uc.a.run.app',
|
createmarket: 'https://createmarket-w3txbmd3ba-uc.a.run.app',
|
||||||
|
creategroup: 'https://creategroup-w3txbmd3ba-uc.a.run.app',
|
||||||
},
|
},
|
||||||
amplitudeApiKey: 'fd8cbfd964b9a205b8678a39faae71b3',
|
amplitudeApiKey: 'fd8cbfd964b9a205b8678a39faae71b3',
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,6 +3,7 @@ export type V2CloudFunction =
|
||||||
| 'sellbet'
|
| 'sellbet'
|
||||||
| 'sellshares'
|
| 'sellshares'
|
||||||
| 'createmarket'
|
| 'createmarket'
|
||||||
|
| 'creategroup'
|
||||||
|
|
||||||
export type EnvConfig = {
|
export type EnvConfig = {
|
||||||
domain: string
|
domain: string
|
||||||
|
@ -52,6 +53,7 @@ export const PROD_CONFIG: EnvConfig = {
|
||||||
sellshares: 'https://sellshares-nggbo3neva-uc.a.run.app',
|
sellshares: 'https://sellshares-nggbo3neva-uc.a.run.app',
|
||||||
sellbet: 'https://sellbet-nggbo3neva-uc.a.run.app',
|
sellbet: 'https://sellbet-nggbo3neva-uc.a.run.app',
|
||||||
createmarket: 'https://createmarket-nggbo3neva-uc.a.run.app',
|
createmarket: 'https://createmarket-nggbo3neva-uc.a.run.app',
|
||||||
|
creategroup: 'https://creategroup-nggbo3neva-uc.a.run.app',
|
||||||
},
|
},
|
||||||
adminEmails: [
|
adminEmails: [
|
||||||
'akrolsmir@gmail.com', // Austin
|
'akrolsmir@gmail.com', // Austin
|
||||||
|
|
|
@ -18,6 +18,7 @@ export const THEOREMONE_CONFIG: EnvConfig = {
|
||||||
sellshares: 'https://sellshares-nggbo3neva-uc.a.run.app',
|
sellshares: 'https://sellshares-nggbo3neva-uc.a.run.app',
|
||||||
sellbet: 'https://sellbet-nggbo3neva-uc.a.run.app',
|
sellbet: 'https://sellbet-nggbo3neva-uc.a.run.app',
|
||||||
createmarket: 'https://createmarket-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'],
|
adminEmails: [...PROD_CONFIG.adminEmails, 'david.glidden@theoremone.co'],
|
||||||
whitelistEmail: '@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 { User } from './user'
|
||||||
import { parseTags } from './util/parse'
|
import { parseTags } from './util/parse'
|
||||||
import { removeUndefinedProps } from './util/object'
|
import { removeUndefinedProps } from './util/object'
|
||||||
|
import { GroupDetails } from 'common/group'
|
||||||
|
|
||||||
export function getNewContract(
|
export function getNewContract(
|
||||||
id: string,
|
id: string,
|
||||||
|
@ -27,7 +28,8 @@ export function getNewContract(
|
||||||
// used for numeric markets
|
// used for numeric markets
|
||||||
bucketCount: number,
|
bucketCount: number,
|
||||||
min: number,
|
min: number,
|
||||||
max: number
|
max: number,
|
||||||
|
groupDetails?: GroupDetails
|
||||||
) {
|
) {
|
||||||
const tags = parseTags(
|
const tags = parseTags(
|
||||||
`${question} ${description} ${extraTags.map((tag) => `#${tag}`).join(' ')}`
|
`${question} ${description} ${extraTags.map((tag) => `#${tag}`).join(' ')}`
|
||||||
|
@ -69,6 +71,7 @@ export function getNewContract(
|
||||||
liquidityFee: 0,
|
liquidityFee: 0,
|
||||||
platformFee: 0,
|
platformFee: 0,
|
||||||
},
|
},
|
||||||
|
groupDetails: groupDetails ? [groupDetails] : undefined,
|
||||||
})
|
})
|
||||||
|
|
||||||
return contract as Contract
|
return contract as Contract
|
||||||
|
|
|
@ -15,10 +15,13 @@ export type Notification = {
|
||||||
sourceUserUsername?: string
|
sourceUserUsername?: string
|
||||||
sourceUserAvatarUrl?: string
|
sourceUserAvatarUrl?: string
|
||||||
sourceText?: string
|
sourceText?: string
|
||||||
|
|
||||||
sourceContractTitle?: string
|
sourceContractTitle?: string
|
||||||
sourceContractCreatorUsername?: string
|
sourceContractCreatorUsername?: string
|
||||||
sourceContractSlug?: string
|
sourceContractSlug?: string
|
||||||
sourceContractTags?: string[]
|
|
||||||
|
sourceSlug?: string
|
||||||
|
sourceTitle?: string
|
||||||
}
|
}
|
||||||
export type notification_source_types =
|
export type notification_source_types =
|
||||||
| 'contract'
|
| 'contract'
|
||||||
|
@ -29,6 +32,7 @@ export type notification_source_types =
|
||||||
| 'follow'
|
| 'follow'
|
||||||
| 'tip'
|
| 'tip'
|
||||||
| 'admin_message'
|
| 'admin_message'
|
||||||
|
| 'group'
|
||||||
|
|
||||||
export type notification_source_update_types =
|
export type notification_source_update_types =
|
||||||
| 'created'
|
| 'created'
|
||||||
|
@ -48,3 +52,4 @@ export type notification_reason_types =
|
||||||
| 'reply_to_users_comment'
|
| 'reply_to_users_comment'
|
||||||
| 'on_new_follow'
|
| 'on_new_follow'
|
||||||
| 'you_follow_user'
|
| 'you_follow_user'
|
||||||
|
| 'added_you_to_group'
|
||||||
|
|
|
@ -7,7 +7,12 @@ import { getPayouts } from './payouts'
|
||||||
export function scoreCreators(contracts: Contract[]) {
|
export function scoreCreators(contracts: Contract[]) {
|
||||||
const creatorScore = mapValues(
|
const creatorScore = mapValues(
|
||||||
groupBy(contracts, ({ creatorId }) => creatorId),
|
groupBy(contracts, ({ creatorId }) => creatorId),
|
||||||
(contracts) => sumBy(contracts, ({ pool }) => pool.YES + pool.NO)
|
(contracts) =>
|
||||||
|
sumBy(
|
||||||
|
contracts.map((contract) => {
|
||||||
|
return contract.volume
|
||||||
|
})
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
return creatorScore
|
return creatorScore
|
||||||
|
|
|
@ -59,6 +59,9 @@ service cloud.firestore {
|
||||||
.hasOnly(['description', 'closeTime'])
|
.hasOnly(['description', 'closeTime'])
|
||||||
&& resource.data.creatorId == request.auth.uid;
|
&& resource.data.creatorId == request.auth.uid;
|
||||||
allow update: if isAdmin();
|
allow update: if isAdmin();
|
||||||
|
match /comments/{commentId} {
|
||||||
|
allow create: if request.auth != null && commentMatchesUser(request.auth.uid, request.resource.data);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
match /{somePath=**}/bets/{betId} {
|
match /{somePath=**}/bets/{betId} {
|
||||||
|
@ -80,20 +83,12 @@ service cloud.firestore {
|
||||||
|
|
||||||
match /{somePath=**}/comments/{commentId} {
|
match /{somePath=**}/comments/{commentId} {
|
||||||
allow read;
|
allow read;
|
||||||
allow create: if request.auth != null && commentMatchesUser(request.auth.uid, request.resource.data);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
match /{somePath=**}/answers/{answerId} {
|
match /{somePath=**}/answers/{answerId} {
|
||||||
allow read;
|
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} {
|
match /{somePath=**}/followers/{userId} {
|
||||||
allow read;
|
allow read;
|
||||||
|
@ -111,5 +106,21 @@ service cloud.firestore {
|
||||||
&& request.resource.data.diff(resource.data).affectedKeys()
|
&& request.resource.data.diff(resource.data).affectedKeys()
|
||||||
.hasOnly(['isSeen', 'viewTime']);
|
.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)
|
const result = schema.safeParse(val)
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
const issues = result.error.issues.map((i) => {
|
const issues = result.error.issues.map((i) => {
|
||||||
|
// TODO: export this type for the front-end to parse
|
||||||
return {
|
return {
|
||||||
field: i.path.join('.') || null,
|
field: i.path.join('.') || null,
|
||||||
error: i.message,
|
error: i.message,
|
||||||
|
|
|
@ -41,7 +41,7 @@ export const backupDb = functions.pubsub
|
||||||
// NOTE: Subcollections are not backed up by default
|
// NOTE: Subcollections are not backed up by default
|
||||||
collectionIds: [
|
collectionIds: [
|
||||||
'contracts',
|
'contracts',
|
||||||
'folds',
|
'groups',
|
||||||
'private-users',
|
'private-users',
|
||||||
'stripe-transactions',
|
'stripe-transactions',
|
||||||
'users',
|
'users',
|
||||||
|
|
|
@ -28,6 +28,7 @@ import { getNoneAnswer } from '../../common/answer'
|
||||||
import { getNewContract } from '../../common/new-contract'
|
import { getNewContract } from '../../common/new-contract'
|
||||||
import { NUMERIC_BUCKET_COUNT } from '../../common/numeric-constants'
|
import { NUMERIC_BUCKET_COUNT } from '../../common/numeric-constants'
|
||||||
import { User } from '../../common/user'
|
import { User } from '../../common/user'
|
||||||
|
import { Group, MAX_ID_LENGTH } from '../../common/group'
|
||||||
|
|
||||||
const bodySchema = z.object({
|
const bodySchema = z.object({
|
||||||
question: z.string().min(1).max(MAX_QUESTION_LENGTH),
|
question: z.string().min(1).max(MAX_QUESTION_LENGTH),
|
||||||
|
@ -38,6 +39,7 @@ const bodySchema = z.object({
|
||||||
'Close time must be in the future.'
|
'Close time must be in the future.'
|
||||||
),
|
),
|
||||||
outcomeType: z.enum(OUTCOME_TYPES),
|
outcomeType: z.enum(OUTCOME_TYPES),
|
||||||
|
groupId: z.string().min(1).max(MAX_ID_LENGTH).optional(),
|
||||||
})
|
})
|
||||||
|
|
||||||
const binarySchema = z.object({
|
const binarySchema = z.object({
|
||||||
|
@ -50,10 +52,8 @@ const numericSchema = z.object({
|
||||||
})
|
})
|
||||||
|
|
||||||
export const createmarket = newEndpoint(['POST'], async (req, auth) => {
|
export const createmarket = newEndpoint(['POST'], async (req, auth) => {
|
||||||
const { question, description, tags, closeTime, outcomeType } = validate(
|
const { question, description, tags, closeTime, outcomeType, groupId } =
|
||||||
bodySchema,
|
validate(bodySchema, req.body)
|
||||||
req.body
|
|
||||||
)
|
|
||||||
|
|
||||||
let min, max, initialProb
|
let min, max, initialProb
|
||||||
if (outcomeType === 'NUMERIC') {
|
if (outcomeType === 'NUMERIC') {
|
||||||
|
@ -77,6 +77,19 @@ export const createmarket = newEndpoint(['POST'], async (req, auth) => {
|
||||||
}
|
}
|
||||||
const user = userDoc.data() as User
|
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
|
const userContractsCreatedTodaySnapshot = await firestore
|
||||||
.collection(`contracts`)
|
.collection(`contracts`)
|
||||||
.where('creatorId', '==', auth.uid)
|
.where('creatorId', '==', auth.uid)
|
||||||
|
@ -115,7 +128,14 @@ export const createmarket = newEndpoint(['POST'], async (req, auth) => {
|
||||||
tags ?? [],
|
tags ?? [],
|
||||||
NUMERIC_BUCKET_COUNT,
|
NUMERIC_BUCKET_COUNT,
|
||||||
min ?? 0,
|
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)
|
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,
|
sourceText: string,
|
||||||
sourceContract?: Contract,
|
sourceContract?: Contract,
|
||||||
relatedSourceType?: notification_source_types,
|
relatedSourceType?: notification_source_types,
|
||||||
relatedUserId?: string
|
relatedUserId?: string,
|
||||||
|
sourceSlug?: string,
|
||||||
|
sourceTitle?: string
|
||||||
) => {
|
) => {
|
||||||
const shouldGetNotification = (
|
const shouldGetNotification = (
|
||||||
userId: string,
|
userId: string,
|
||||||
|
@ -63,10 +65,12 @@ export const createNotification = async (
|
||||||
sourceUserUsername: sourceUser.username,
|
sourceUserUsername: sourceUser.username,
|
||||||
sourceUserAvatarUrl: sourceUser.avatarUrl,
|
sourceUserAvatarUrl: sourceUser.avatarUrl,
|
||||||
sourceText,
|
sourceText,
|
||||||
sourceContractTitle: sourceContract?.question,
|
|
||||||
sourceContractCreatorUsername: sourceContract?.creatorUsername,
|
sourceContractCreatorUsername: sourceContract?.creatorUsername,
|
||||||
|
// TODO: move away from sourceContractTitle to sourceTitle
|
||||||
|
sourceContractTitle: sourceContract?.question,
|
||||||
sourceContractSlug: sourceContract?.slug,
|
sourceContractSlug: sourceContract?.slug,
|
||||||
sourceContractTags: sourceContract?.tags,
|
sourceSlug: sourceSlug ? sourceSlug : sourceContract?.slug,
|
||||||
|
sourceTitle: sourceTitle ? sourceTitle : sourceContract?.question,
|
||||||
}
|
}
|
||||||
await notificationRef.set(removeUndefinedProps(notification))
|
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 getUsersToNotify = async () => {
|
||||||
const userToReasonTexts: user_to_reason_texts = {}
|
const userToReasonTexts: user_to_reason_texts = {}
|
||||||
// The following functions modify the userToReasonTexts object in place.
|
// The following functions modify the userToReasonTexts object in place.
|
||||||
|
@ -273,6 +287,9 @@ export const createNotification = async (
|
||||||
}
|
}
|
||||||
} else if (sourceType === 'follow' && relatedUserId) {
|
} else if (sourceType === 'follow' && relatedUserId) {
|
||||||
await notifyFollowedUser(userToReasonTexts, relatedUserId)
|
await notifyFollowedUser(userToReasonTexts, relatedUserId)
|
||||||
|
} else if (sourceType === 'group' && relatedUserId) {
|
||||||
|
if (sourceUpdateType === 'created')
|
||||||
|
await notifyUserAddedToGroup(userToReasonTexts, relatedUserId)
|
||||||
}
|
}
|
||||||
return userToReasonTexts
|
return userToReasonTexts
|
||||||
}
|
}
|
||||||
|
|
|
@ -8,12 +8,9 @@ export * from './transact'
|
||||||
export * from './resolve-market'
|
export * from './resolve-market'
|
||||||
export * from './stripe'
|
export * from './stripe'
|
||||||
export * from './create-user'
|
export * from './create-user'
|
||||||
export * from './create-fold'
|
|
||||||
export * from './create-answer'
|
export * from './create-answer'
|
||||||
export * from './on-create-bet'
|
export * from './on-create-bet'
|
||||||
export * from './on-create-comment'
|
export * from './on-create-comment'
|
||||||
export * from './on-fold-follow'
|
|
||||||
export * from './on-fold-delete'
|
|
||||||
export * from './on-view'
|
export * from './on-view'
|
||||||
export * from './unsubscribe'
|
export * from './unsubscribe'
|
||||||
export * from './update-metrics'
|
export * from './update-metrics'
|
||||||
|
@ -27,6 +24,8 @@ export * from './on-create-contract'
|
||||||
export * from './on-follow-user'
|
export * from './on-follow-user'
|
||||||
export * from './on-unfollow-user'
|
export * from './on-unfollow-user'
|
||||||
export * from './on-create-liquidity-provision'
|
export * from './on-create-liquidity-provision'
|
||||||
|
export * from './on-update-group'
|
||||||
|
export * from './on-create-group'
|
||||||
|
|
||||||
// v2
|
// v2
|
||||||
export * from './health'
|
export * from './health'
|
||||||
|
@ -35,3 +34,4 @@ export * from './sell-bet'
|
||||||
export * from './sell-shares'
|
export * from './sell-shares'
|
||||||
export * from './create-contract'
|
export * from './create-contract'
|
||||||
export * from './withdraw-liquidity'
|
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
|
label?: string
|
||||||
className?: string
|
className?: string
|
||||||
}
|
}
|
||||||
onSubmit: () => void
|
|
||||||
children: ReactNode
|
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)
|
const [open, setOpen] = useState(false)
|
||||||
|
|
||||||
|
function updateOpen(newOpen: boolean) {
|
||||||
|
onOpenChanged?.(newOpen)
|
||||||
|
setOpen(newOpen)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Modal open={open} setOpen={setOpen}>
|
<Modal open={open} setOpen={updateOpen}>
|
||||||
<Col className="gap-4 rounded-md bg-white px-8 py-6">
|
<Col className="gap-4 rounded-md bg-white px-8 py-6">
|
||||||
{children}
|
{children}
|
||||||
<Row className="gap-4">
|
<Row className="gap-4">
|
||||||
<button
|
<div
|
||||||
className={clsx('btn', cancelBtn?.className)}
|
className={clsx('btn normal-case', cancelBtn?.className)}
|
||||||
onClick={() => setOpen(false)}
|
onClick={() => updateOpen(false)}
|
||||||
>
|
>
|
||||||
{cancelBtn?.label ?? 'Cancel'}
|
{cancelBtn?.label ?? 'Cancel'}
|
||||||
</button>
|
</div>
|
||||||
<button
|
<div
|
||||||
className={clsx('btn', submitBtn?.className)}
|
className={clsx('btn normal-case', submitBtn?.className)}
|
||||||
onClick={onSubmit}
|
onClick={
|
||||||
|
onSubmitWithSuccess
|
||||||
|
? () =>
|
||||||
|
onSubmitWithSuccess().then((success) =>
|
||||||
|
updateOpen(!success)
|
||||||
|
)
|
||||||
|
: onSubmit
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{submitBtn?.label ?? 'Submit'}
|
{submitBtn?.label ?? 'Submit'}
|
||||||
</button>
|
</div>
|
||||||
</Row>
|
</Row>
|
||||||
</Col>
|
</Col>
|
||||||
</Modal>
|
</Modal>
|
||||||
<button
|
<div
|
||||||
className={clsx('btn', openModalBtn.className)}
|
className={clsx('btn normal-case', openModalBtn.className)}
|
||||||
onClick={() => setOpen(true)}
|
onClick={() => updateOpen(true)}
|
||||||
>
|
>
|
||||||
|
{openModalBtn.icon}
|
||||||
{openModalBtn.label}
|
{openModalBtn.label}
|
||||||
</button>
|
</div>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -31,8 +31,10 @@ export function ContractCard(props: {
|
||||||
showCloseTime?: boolean
|
showCloseTime?: boolean
|
||||||
className?: string
|
className?: string
|
||||||
onClick?: () => void
|
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 contract = useContractWithPreload(props.contract) ?? props.contract
|
||||||
const { question, outcomeType } = contract
|
const { question, outcomeType } = contract
|
||||||
const { resolution } = contract
|
const { resolution } = contract
|
||||||
|
@ -42,12 +44,14 @@ export function ContractCard(props: {
|
||||||
const marketClosed =
|
const marketClosed =
|
||||||
(contract.closeTime || Infinity) < Date.now() || !!resolution
|
(contract.closeTime || Infinity) < Date.now() || !!resolution
|
||||||
|
|
||||||
const showQuickBet = !(
|
const showQuickBet =
|
||||||
!user ||
|
user &&
|
||||||
marketClosed ||
|
!marketClosed &&
|
||||||
(outcomeType === 'FREE_RESPONSE' && getTopAnswer(contract) === undefined) ||
|
!(
|
||||||
outcomeType === 'NUMERIC'
|
outcomeType === 'FREE_RESPONSE' && getTopAnswer(contract) === undefined
|
||||||
)
|
) &&
|
||||||
|
outcomeType !== 'NUMERIC' &&
|
||||||
|
!hideQuickBet
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
|
|
|
@ -3,6 +3,7 @@ import {
|
||||||
DatabaseIcon,
|
DatabaseIcon,
|
||||||
PencilIcon,
|
PencilIcon,
|
||||||
TrendingUpIcon,
|
TrendingUpIcon,
|
||||||
|
UserGroupIcon,
|
||||||
} from '@heroicons/react/outline'
|
} from '@heroicons/react/outline'
|
||||||
import { Row } from '../layout/row'
|
import { Row } from '../layout/row'
|
||||||
import { formatMoney } from 'common/util/format'
|
import { formatMoney } from 'common/util/format'
|
||||||
|
@ -24,6 +25,8 @@ import NewContractBadge from '../new-contract-badge'
|
||||||
import { CATEGORY_LIST } from 'common/categories'
|
import { CATEGORY_LIST } from 'common/categories'
|
||||||
import { TagsList } from '../tags-list'
|
import { TagsList } from '../tags-list'
|
||||||
import { UserFollowButton } from '../follow-button'
|
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'
|
import { DAY_MS } from 'common/util/time'
|
||||||
|
|
||||||
export function MiscDetails(props: {
|
export function MiscDetails(props: {
|
||||||
|
@ -107,7 +110,8 @@ export function ContractDetails(props: {
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
}) {
|
}) {
|
||||||
const { contract, bets, isCreator, disabled } = props
|
const { contract, bets, isCreator, disabled } = props
|
||||||
const { closeTime, creatorName, creatorUsername, creatorId } = contract
|
const { closeTime, creatorName, creatorUsername, creatorId, groupDetails } =
|
||||||
|
contract
|
||||||
const { volumeLabel, resolvedDate } = contractMetrics(contract)
|
const { volumeLabel, resolvedDate } = contractMetrics(contract)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -130,18 +134,21 @@ export function ContractDetails(props: {
|
||||||
)}
|
)}
|
||||||
{!disabled && <UserFollowButton userId={creatorId} small />}
|
{!disabled && <UserFollowButton userId={creatorId} small />}
|
||||||
</Row>
|
</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) && (
|
{(!!closeTime || !!resolvedDate) && (
|
||||||
<Row className="items-center gap-1">
|
<Row className="items-center gap-1">
|
||||||
<ClockIcon className="h-5 w-5" />
|
<ClockIcon className="h-5 w-5" />
|
||||||
|
|
||||||
{/* <DateTimeTooltip text="Market created:" time={contract.createdTime}>
|
|
||||||
{createdDate}
|
|
||||||
</DateTimeTooltip> */}
|
|
||||||
|
|
||||||
{resolvedDate && contract.resolutionTime ? (
|
{resolvedDate && contract.resolutionTime ? (
|
||||||
<>
|
<>
|
||||||
{/* {' - '} */}
|
|
||||||
<DateTimeTooltip
|
<DateTimeTooltip
|
||||||
text="Market resolved:"
|
text="Market resolved:"
|
||||||
time={contract.resolutionTime}
|
time={contract.resolutionTime}
|
||||||
|
@ -153,7 +160,6 @@ export function ContractDetails(props: {
|
||||||
|
|
||||||
{!resolvedDate && closeTime && (
|
{!resolvedDate && closeTime && (
|
||||||
<>
|
<>
|
||||||
{/* {' - '}{' '} */}
|
|
||||||
<EditableCloseDate
|
<EditableCloseDate
|
||||||
closeTime={closeTime}
|
closeTime={closeTime}
|
||||||
contract={contract}
|
contract={contract}
|
||||||
|
|
|
@ -18,10 +18,10 @@ import { Col } from '../layout/col'
|
||||||
import { Modal } from '../layout/modal'
|
import { Modal } from '../layout/modal'
|
||||||
import { Row } from '../layout/row'
|
import { Row } from '../layout/row'
|
||||||
import { ShareEmbedButton } from '../share-embed-button'
|
import { ShareEmbedButton } from '../share-embed-button'
|
||||||
import { TagsInput } from '../tags-input'
|
|
||||||
import { Title } from '../title'
|
import { Title } from '../title'
|
||||||
import { TweetButton } from '../tweet-button'
|
import { TweetButton } from '../tweet-button'
|
||||||
import { InfoTooltip } from '../info-tooltip'
|
import { InfoTooltip } from '../info-tooltip'
|
||||||
|
import { TagsInput } from 'web/components/tags-input'
|
||||||
|
|
||||||
export function ContractInfoDialog(props: { contract: Contract; bets: Bet[] }) {
|
export function ContractInfoDialog(props: { contract: Contract; bets: Bet[] }) {
|
||||||
const { contract, bets } = props
|
const { contract, bets } = props
|
||||||
|
@ -150,7 +150,6 @@ export function ContractInfoDialog(props: { contract: Contract; bets: Bet[] }) {
|
||||||
<div>Tags</div>
|
<div>Tags</div>
|
||||||
<TagsInput contract={contract} />
|
<TagsInput contract={contract} />
|
||||||
<div />
|
<div />
|
||||||
|
|
||||||
{contract.mechanism === 'cpmm-1' && !contract.resolution && (
|
{contract.mechanism === 'cpmm-1' && !contract.resolution && (
|
||||||
<LiquidityPanel contract={contract} />
|
<LiquidityPanel contract={contract} />
|
||||||
)}
|
)}
|
||||||
|
|
|
@ -6,6 +6,7 @@ import { ContractCard } from './contract-card'
|
||||||
import { ContractSearch } from '../contract-search'
|
import { ContractSearch } from '../contract-search'
|
||||||
import { useIsVisible } from 'web/hooks/use-is-visible'
|
import { useIsVisible } from 'web/hooks/use-is-visible'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
|
import clsx from 'clsx'
|
||||||
|
|
||||||
export function ContractsGrid(props: {
|
export function ContractsGrid(props: {
|
||||||
contracts: Contract[]
|
contracts: Contract[]
|
||||||
|
@ -13,8 +14,18 @@ export function ContractsGrid(props: {
|
||||||
hasMore: boolean
|
hasMore: boolean
|
||||||
showCloseTime?: boolean
|
showCloseTime?: boolean
|
||||||
onContractClick?: (contract: Contract) => void
|
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 [elem, setElem] = useState<HTMLElement | null>(null)
|
||||||
const isBottomVisible = useIsVisible(elem)
|
const isBottomVisible = useIsVisible(elem)
|
||||||
|
@ -38,7 +49,13 @@ export function ContractsGrid(props: {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Col className="gap-8">
|
<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) => (
|
{contracts.map((contract) => (
|
||||||
<ContractCard
|
<ContractCard
|
||||||
contract={contract}
|
contract={contract}
|
||||||
|
@ -47,6 +64,7 @@ export function ContractsGrid(props: {
|
||||||
onClick={
|
onClick={
|
||||||
onContractClick ? () => onContractClick(contract) : undefined
|
onContractClick ? () => onContractClick(contract) : undefined
|
||||||
}
|
}
|
||||||
|
hideQuickBet={hideQuickBet}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</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'
|
import clsx from 'clsx'
|
||||||
|
|
||||||
export function CopyLinkDateTimeComponent(props: {
|
export function CopyLinkDateTimeComponent(props: {
|
||||||
contractCreatorUsername: string
|
prefix: string
|
||||||
contractSlug: string
|
slug: string
|
||||||
createdTime: number
|
createdTime: number
|
||||||
elementId: string
|
elementId: string
|
||||||
className?: string
|
className?: string
|
||||||
}) {
|
}) {
|
||||||
const {
|
const { prefix, slug, elementId, createdTime, className } = props
|
||||||
contractCreatorUsername,
|
|
||||||
contractSlug,
|
|
||||||
elementId,
|
|
||||||
createdTime,
|
|
||||||
className,
|
|
||||||
} = props
|
|
||||||
const [showToast, setShowToast] = useState(false)
|
const [showToast, setShowToast] = useState(false)
|
||||||
|
|
||||||
function copyLinkToComment(
|
function copyLinkToComment(
|
||||||
event: React.MouseEvent<HTMLAnchorElement, MouseEvent>
|
event: React.MouseEvent<HTMLAnchorElement, MouseEvent>
|
||||||
) {
|
) {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
const elementLocation = `https://${ENV_CONFIG.domain}/${contractCreatorUsername}/${contractSlug}#${elementId}`
|
const elementLocation = `https://${ENV_CONFIG.domain}/${prefix}/${slug}#${elementId}`
|
||||||
|
|
||||||
copyToClipboard(elementLocation)
|
copyToClipboard(elementLocation)
|
||||||
setShowToast(true)
|
setShowToast(true)
|
||||||
|
@ -37,10 +31,7 @@ export function CopyLinkDateTimeComponent(props: {
|
||||||
return (
|
return (
|
||||||
<div className={clsx('inline', className)}>
|
<div className={clsx('inline', className)}>
|
||||||
<DateTimeTooltip time={createdTime}>
|
<DateTimeTooltip time={createdTime}>
|
||||||
<Link
|
<Link href={`/${prefix}/${slug}#${elementId}`} passHref={true}>
|
||||||
href={`/${contractCreatorUsername}/${contractSlug}#${elementId}`}
|
|
||||||
passHref={true}
|
|
||||||
>
|
|
||||||
<a
|
<a
|
||||||
onClick={(event) => copyLinkToComment(event)}
|
onClick={(event) => copyLinkToComment(event)}
|
||||||
className={'mx-1 cursor-pointer'}
|
className={'mx-1 cursor-pointer'}
|
||||||
|
|
|
@ -152,8 +152,8 @@ export function FeedAnswerCommentGroup(props: {
|
||||||
<div className="text-sm text-gray-500">
|
<div className="text-sm text-gray-500">
|
||||||
<UserLink username={username} name={name} /> answered
|
<UserLink username={username} name={name} /> answered
|
||||||
<CopyLinkDateTimeComponent
|
<CopyLinkDateTimeComponent
|
||||||
contractCreatorUsername={contract.creatorUsername}
|
prefix={contract.creatorUsername}
|
||||||
contractSlug={contract.slug}
|
slug={contract.slug}
|
||||||
createdTime={answer.createdTime}
|
createdTime={answer.createdTime}
|
||||||
elementId={answerElementId}
|
elementId={answerElementId}
|
||||||
/>
|
/>
|
||||||
|
@ -234,7 +234,10 @@ export function FeedAnswerCommentGroup(props: {
|
||||||
parentAnswerOutcome={answer.number.toString()}
|
parentAnswerOutcome={answer.number.toString()}
|
||||||
replyToUsername={replyToUsername}
|
replyToUsername={replyToUsername}
|
||||||
setRef={setInputRef}
|
setRef={setInputRef}
|
||||||
onSubmitComment={() => setShowReply(false)}
|
onSubmitComment={() => {
|
||||||
|
setShowReply(false)
|
||||||
|
setReplyToUsername('')
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
@ -15,7 +15,10 @@ import { OutcomeLabel } from 'web/components/outcome-label'
|
||||||
import { CopyLinkDateTimeComponent } from 'web/components/feed/copy-link-date-time'
|
import { CopyLinkDateTimeComponent } from 'web/components/feed/copy-link-date-time'
|
||||||
import { contractPath } from 'web/lib/firebase/contracts'
|
import { contractPath } from 'web/lib/firebase/contracts'
|
||||||
import { firebaseLogin } from 'web/lib/firebase/users'
|
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 Textarea from 'react-expanding-textarea'
|
||||||
import { Linkify } from 'web/components/linkify'
|
import { Linkify } from 'web/components/linkify'
|
||||||
import { SiteLink } from 'web/components/site-link'
|
import { SiteLink } from 'web/components/site-link'
|
||||||
|
@ -25,6 +28,7 @@ import { getProbability } from 'common/calculate'
|
||||||
import { LoadingIndicator } from 'web/components/loading-indicator'
|
import { LoadingIndicator } from 'web/components/loading-indicator'
|
||||||
import { PaperAirplaneIcon } from '@heroicons/react/outline'
|
import { PaperAirplaneIcon } from '@heroicons/react/outline'
|
||||||
import { track } from 'web/lib/service/analytics'
|
import { track } from 'web/lib/service/analytics'
|
||||||
|
import { useEvent } from 'web/hooks/use-event'
|
||||||
import { Tipper } from '../tipper'
|
import { Tipper } from '../tipper'
|
||||||
import { CommentTipMap, CommentTips } from 'web/hooks/use-tip-txns'
|
import { CommentTipMap, CommentTips } from 'web/hooks/use-tip-txns'
|
||||||
|
|
||||||
|
@ -96,7 +100,10 @@ export function FeedCommentThread(props: {
|
||||||
replyToUsername={replyToUsername}
|
replyToUsername={replyToUsername}
|
||||||
parentAnswerOutcome={comments[0].answerOutcome}
|
parentAnswerOutcome={comments[0].answerOutcome}
|
||||||
setRef={setInputRef}
|
setRef={setInputRef}
|
||||||
onSubmitComment={() => setShowReply(false)}
|
onSubmitComment={() => {
|
||||||
|
setShowReply(false)
|
||||||
|
setReplyToUsername('')
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
@ -262,8 +269,8 @@ export function FeedComment(props: {
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
<CopyLinkDateTimeComponent
|
<CopyLinkDateTimeComponent
|
||||||
contractCreatorUsername={contract.creatorUsername}
|
prefix={contract.creatorUsername}
|
||||||
contractSlug={contract.slug}
|
slug={contract.slug}
|
||||||
createdTime={createdTime}
|
createdTime={createdTime}
|
||||||
elementId={comment.id}
|
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: {
|
export function CommentInput(props: {
|
||||||
contract: Contract
|
contract: Contract
|
||||||
betsByCurrentUser: Bet[]
|
betsByCurrentUser: Bet[]
|
||||||
|
@ -366,12 +374,6 @@ export function CommentInput(props: {
|
||||||
)
|
)
|
||||||
const { id } = mostRecentCommentableBet || { id: undefined }
|
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) {
|
async function submitComment(betId: string | undefined) {
|
||||||
if (!user) {
|
if (!user) {
|
||||||
track('sign in to comment')
|
track('sign in to comment')
|
||||||
|
@ -379,7 +381,7 @@ export function CommentInput(props: {
|
||||||
}
|
}
|
||||||
if (!comment || isSubmitting) return
|
if (!comment || isSubmitting) return
|
||||||
setIsSubmitting(true)
|
setIsSubmitting(true)
|
||||||
await createComment(
|
await createCommentOnContract(
|
||||||
contract.id,
|
contract.id,
|
||||||
comment,
|
comment,
|
||||||
user,
|
user,
|
||||||
|
@ -403,7 +405,7 @@ export function CommentInput(props: {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Row className={'mb-2 gap-1 sm:gap-2'}>
|
<Row className={'mb-2 gap-1 sm:gap-2'}>
|
||||||
<div className={''}>
|
<div className={'mt-2'}>
|
||||||
<Avatar
|
<Avatar
|
||||||
avatarUrl={user?.avatarUrl}
|
avatarUrl={user?.avatarUrl}
|
||||||
username={user?.username}
|
username={user?.username}
|
||||||
|
@ -442,46 +444,98 @@ export function CommentInput(props: {
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<CommentInputTextArea
|
||||||
|
commentText={comment}
|
||||||
|
setComment={setComment}
|
||||||
|
isReply={!!parentCommentId || !!parentAnswerOutcome}
|
||||||
|
replyToUsername={replyToUsername ?? ''}
|
||||||
|
user={user}
|
||||||
|
submitComment={submitComment}
|
||||||
|
isSubmitting={isSubmitting}
|
||||||
|
setRef={setRef}
|
||||||
|
presetId={id}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Row>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CommentInputTextArea(props: {
|
||||||
|
user: User | undefined | null
|
||||||
|
isReply: boolean
|
||||||
|
replyToUsername: string
|
||||||
|
commentText: string
|
||||||
|
setComment: (text: string) => void
|
||||||
|
submitComment: (id?: string) => void
|
||||||
|
isSubmitting: boolean
|
||||||
|
setRef?: (ref: HTMLTextAreaElement) => void
|
||||||
|
presetId?: string
|
||||||
|
enterToSubmit?: boolean
|
||||||
|
}) {
|
||||||
|
const {
|
||||||
|
isReply,
|
||||||
|
setRef,
|
||||||
|
user,
|
||||||
|
commentText,
|
||||||
|
setComment,
|
||||||
|
submitComment,
|
||||||
|
presetId,
|
||||||
|
isSubmitting,
|
||||||
|
replyToUsername,
|
||||||
|
enterToSubmit,
|
||||||
|
} = props
|
||||||
|
|
||||||
|
const memoizedSetComment = useEvent(setComment)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!replyToUsername || !user || replyToUsername === user.username) return
|
||||||
|
const replacement = `@${replyToUsername} `
|
||||||
|
memoizedSetComment(replacement + commentText.replace(replacement, ''))
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [user, replyToUsername, memoizedSetComment])
|
||||||
|
return (
|
||||||
|
<>
|
||||||
<Row className="gap-1.5 text-gray-700">
|
<Row className="gap-1.5 text-gray-700">
|
||||||
<Textarea
|
<Textarea
|
||||||
ref={setRef}
|
ref={setRef}
|
||||||
value={comment}
|
value={commentText}
|
||||||
onChange={(e) => setComment(e.target.value)}
|
onChange={(e) => setComment(e.target.value)}
|
||||||
className={clsx(
|
className={clsx('textarea textarea-bordered w-full resize-none')}
|
||||||
'textarea textarea-bordered w-full resize-none'
|
|
||||||
)}
|
|
||||||
// Make room for floating submit button.
|
// Make room for floating submit button.
|
||||||
style={{ paddingRight: 48 }}
|
style={{ paddingRight: 48 }}
|
||||||
placeholder={
|
placeholder={
|
||||||
parentCommentId || parentAnswerOutcome
|
isReply
|
||||||
? 'Write a reply... '
|
? 'Write a reply... '
|
||||||
|
: enterToSubmit
|
||||||
|
? 'Send a message'
|
||||||
: 'Write a comment...'
|
: 'Write a comment...'
|
||||||
}
|
}
|
||||||
autoFocus={false}
|
autoFocus={false}
|
||||||
maxLength={MAX_COMMENT_LENGTH}
|
maxLength={MAX_COMMENT_LENGTH}
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
|
if (
|
||||||
|
(enterToSubmit && e.key === 'Enter' && !e.shiftKey) ||
|
||||||
|
(e.key === 'Enter' && (e.ctrlKey || e.metaKey))
|
||||||
|
) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
submitComment(id)
|
submitComment(presetId)
|
||||||
e.currentTarget.blur()
|
e.currentTarget.blur()
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Col className={clsx('justify-end')}>
|
<Col className={clsx('relative justify-end')}>
|
||||||
{user && !isSubmitting && (
|
{user && !isSubmitting && (
|
||||||
<button
|
<button
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'btn btn-ghost btn-sm absolute right-2 flex-row pl-2 capitalize',
|
'btn btn-ghost btn-sm absolute right-2 flex-row pl-2 capitalize',
|
||||||
parentCommentId || parentAnswerOutcome
|
isReply ? ' bottom-4' : ' bottom-2',
|
||||||
? ' bottom-4'
|
!commentText && 'pointer-events-none text-gray-500'
|
||||||
: ' bottom-2',
|
|
||||||
!comment && 'pointer-events-none text-gray-500'
|
|
||||||
)}
|
)}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
submitComment(id)
|
submitComment(presetId)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<PaperAirplaneIcon
|
<PaperAirplaneIcon
|
||||||
|
@ -499,15 +553,12 @@ export function CommentInput(props: {
|
||||||
{!user && (
|
{!user && (
|
||||||
<button
|
<button
|
||||||
className={'btn btn-outline btn-sm mt-2 normal-case'}
|
className={'btn btn-outline btn-sm mt-2 normal-case'}
|
||||||
onClick={() => submitComment(id)}
|
onClick={() => submitComment(presetId)}
|
||||||
>
|
>
|
||||||
Sign in to comment
|
Sign in to comment
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</Row>
|
</Row>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Row>
|
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -39,9 +39,11 @@ export function findActiveContracts(
|
||||||
|
|
||||||
// Add every contract that had a recent comment, too
|
// Add every contract that had a recent comment, too
|
||||||
for (const comment of recentComments) {
|
for (const comment of recentComments) {
|
||||||
|
if (comment.contractId) {
|
||||||
const contract = contractsById.get(comment.contractId)
|
const contract = contractsById.get(comment.contractId)
|
||||||
if (contract) record(contract.id, comment.createdTime)
|
if (contract) record(contract.id, comment.createdTime)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Add contracts by last bet time.
|
// Add contracts by last bet time.
|
||||||
const contractBets = groupBy(recentBets, (bet) => bet.contractId)
|
const contractBets = groupBy(recentBets, (bet) => bet.contractId)
|
||||||
|
|
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 { useState } from 'react'
|
||||||
import { isEqual } from 'lodash'
|
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
import { PencilIcon } from '@heroicons/react/outline'
|
import { PencilIcon } from '@heroicons/react/outline'
|
||||||
|
|
||||||
import { Fold } from 'common/fold'
|
import { Group } from 'common/group'
|
||||||
import { parseWordsAsTags } from 'common/util/parse'
|
import { deleteGroup, updateGroup } from 'web/lib/firebase/groups'
|
||||||
import { deleteFold, updateFold } from 'web/lib/firebase/folds'
|
|
||||||
import { toCamelCase } from 'common/util/format'
|
|
||||||
import { Spacer } from '../layout/spacer'
|
import { Spacer } from '../layout/spacer'
|
||||||
import { TagsList } from '../tags-list'
|
|
||||||
import { useRouter } from 'next/router'
|
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 }) {
|
export function EditGroupButton(props: { group: Group; className?: string }) {
|
||||||
const { fold, className } = props
|
const { group, className } = props
|
||||||
|
const { memberIds } = group
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
const [name, setName] = useState(fold.name)
|
const [name, setName] = useState(group.name)
|
||||||
const [about, setAbout] = useState(fold.about ?? '')
|
const [about, setAbout] = useState(group.about ?? '')
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
const initialOtherTags =
|
|
||||||
fold?.tags.filter((tag) => tag !== toCamelCase(name)).join(', ') ?? ''
|
|
||||||
|
|
||||||
const [otherTags, setOtherTags] = useState(initialOtherTags)
|
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||||
|
const [addMemberUsers, setAddMemberUsers] = useState<User[]>([])
|
||||||
|
|
||||||
const tags = parseWordsAsTags(toCamelCase(name) + ' ' + otherTags)
|
function updateOpen(newOpen: boolean) {
|
||||||
const lowercaseTags = tags.map((tag) => tag.toLowerCase())
|
setAddMemberUsers([])
|
||||||
|
setOpen(newOpen)
|
||||||
|
}
|
||||||
|
|
||||||
const saveDisabled =
|
const saveDisabled =
|
||||||
name === fold.name &&
|
name === group.name && about === group.about && addMemberUsers.length === 0
|
||||||
isEqual(tags, fold.tags) &&
|
|
||||||
about === (fold.about ?? '')
|
|
||||||
|
|
||||||
const onSubmit = async () => {
|
const onSubmit = async () => {
|
||||||
setIsSubmitting(true)
|
setIsSubmitting(true)
|
||||||
|
|
||||||
await updateFold(fold, {
|
await updateGroup(group, {
|
||||||
name,
|
name,
|
||||||
about,
|
about,
|
||||||
tags,
|
memberIds: [...memberIds, ...addMemberUsers.map((user) => user.id)],
|
||||||
lowercaseTags,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
setIsSubmitting(false)
|
setIsSubmitting(false)
|
||||||
|
updateOpen(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={clsx('p-1', className)}>
|
<div className={clsx('flex p-1', className)}>
|
||||||
<label
|
<div
|
||||||
htmlFor="edit"
|
|
||||||
className={clsx(
|
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
|
<PencilIcon className="inline h-4 w-4" /> Edit
|
||||||
</label>
|
</div>
|
||||||
<input type="checkbox" id="edit" className="modal-toggle" />
|
<Modal open={open} setOpen={updateOpen}>
|
||||||
|
<div className="h-full rounded-md bg-white p-8">
|
||||||
<div className="modal">
|
|
||||||
<div className="modal-box">
|
|
||||||
<div className="form-control w-full">
|
<div className="form-control w-full">
|
||||||
<label className="label">
|
<label className="label">
|
||||||
<span className="mb-1">Community name</span>
|
<span className="mb-1">Group name</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<input
|
<input
|
||||||
placeholder="Your fold name"
|
placeholder="Your group name"
|
||||||
className="input input-bordered resize-none"
|
className="input input-bordered resize-none"
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
value={name}
|
value={name}
|
||||||
|
@ -94,29 +89,23 @@ export function EditFoldButton(props: { fold: Fold; className?: string }) {
|
||||||
|
|
||||||
<div className="form-control w-full">
|
<div className="form-control w-full">
|
||||||
<label className="label">
|
<label className="label">
|
||||||
<span className="mb-1">Tags</span>
|
<span className="mb-0">Add members</span>
|
||||||
</label>
|
</label>
|
||||||
|
<FilterSelectUsers
|
||||||
<input
|
setSelectedUsers={setAddMemberUsers}
|
||||||
placeholder="Politics, Economics, Rationality"
|
selectedUsers={addMemberUsers}
|
||||||
className="input input-bordered resize-none"
|
ignoreUserIds={memberIds}
|
||||||
disabled={isSubmitting}
|
|
||||||
value={otherTags}
|
|
||||||
onChange={(e) => setOtherTags(e.target.value || '')}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Spacer h={4} />
|
|
||||||
<TagsList tags={tags} noLink noLabel />
|
|
||||||
<Spacer h={4} />
|
|
||||||
|
|
||||||
<div className="modal-action">
|
<div className="modal-action">
|
||||||
<label
|
<label
|
||||||
htmlFor="edit"
|
htmlFor="edit"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (confirm('Are you sure you want to delete this fold?')) {
|
if (confirm('Are you sure you want to delete this group?')) {
|
||||||
deleteFold(fold)
|
deleteGroup(group)
|
||||||
router.replace('/folds')
|
updateOpen(false)
|
||||||
|
router.replace('/groups')
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
|
@ -125,7 +114,11 @@ export function EditFoldButton(props: { fold: Fold; className?: string }) {
|
||||||
>
|
>
|
||||||
Delete
|
Delete
|
||||||
</label>
|
</label>
|
||||||
<label htmlFor="edit" className={clsx('btn')}>
|
<label
|
||||||
|
htmlFor="edit"
|
||||||
|
className={'btn'}
|
||||||
|
onClick={() => updateOpen(false)}
|
||||||
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</label>
|
</label>
|
||||||
<label
|
<label
|
||||||
|
@ -141,7 +134,7 @@ export function EditFoldButton(props: { fold: Fold; className?: string }) {
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Modal>
|
||||||
</div>
|
</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 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
|
const { children, className, ...rest } = props
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={clsx(className, 'flex flex-col')} {...rest}>
|
<div className={clsx(className, 'flex flex-col')} ref={ref} {...rest}>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
})
|
||||||
|
|
|
@ -1,11 +1,14 @@
|
||||||
import clsx from 'clsx'
|
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
|
const { children, className, ...rest } = props
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={clsx(className, 'flex flex-row')} {...rest}>
|
<div className={clsx(className, 'flex flex-row')} ref={ref} {...rest}>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
})
|
||||||
|
|
|
@ -1,15 +1,24 @@
|
||||||
import { Fragment } from 'react'
|
import React, { Fragment } from 'react'
|
||||||
import { Menu, Transition } from '@headlessui/react'
|
import { Menu, Transition } from '@headlessui/react'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
|
|
||||||
|
export type MenuItem = {
|
||||||
|
name: string
|
||||||
|
href: string
|
||||||
|
onClick?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
export function MenuButton(props: {
|
export function MenuButton(props: {
|
||||||
buttonContent: JSX.Element
|
buttonContent: JSX.Element
|
||||||
menuItems: { name: string; href: string; onClick?: () => void }[]
|
menuItems: MenuItem[]
|
||||||
className?: string
|
className?: string
|
||||||
}) {
|
}) {
|
||||||
const { buttonContent, menuItems, className } = props
|
const { buttonContent, menuItems, className } = props
|
||||||
return (
|
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>
|
<div>
|
||||||
<Menu.Button className="w-full rounded-full">
|
<Menu.Button className="w-full rounded-full">
|
||||||
<span className="sr-only">Open user menu</span>
|
<span className="sr-only">Open user menu</span>
|
||||||
|
@ -25,9 +34,9 @@ export function MenuButton(props: {
|
||||||
leaveFrom="transform opacity-100 scale-100"
|
leaveFrom="transform opacity-100 scale-100"
|
||||||
leaveTo="transform opacity-0 scale-95"
|
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) => (
|
{menuItems.map((item) => (
|
||||||
<Menu.Item key={item.name}>
|
<Menu.Item key={item.href}>
|
||||||
{({ active }) => (
|
{({ active }) => (
|
||||||
<a
|
<a
|
||||||
href={item.href}
|
href={item.href}
|
||||||
|
@ -35,7 +44,7 @@ export function MenuButton(props: {
|
||||||
onClick={item.onClick}
|
onClick={item.onClick}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
active ? 'bg-gray-100' : '',
|
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}
|
{item.name}
|
||||||
|
|
|
@ -9,13 +9,15 @@ import {
|
||||||
PresentationChartBarIcon,
|
PresentationChartBarIcon,
|
||||||
SparklesIcon,
|
SparklesIcon,
|
||||||
NewspaperIcon,
|
NewspaperIcon,
|
||||||
|
UserGroupIcon,
|
||||||
|
ChevronDownIcon,
|
||||||
TrendingUpIcon,
|
TrendingUpIcon,
|
||||||
} from '@heroicons/react/outline'
|
} from '@heroicons/react/outline'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { useRouter } from 'next/router'
|
import { useRouter } from 'next/router'
|
||||||
import { useUser } from 'web/hooks/use-user'
|
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 { ManifoldLogo } from './manifold-logo'
|
||||||
import { MenuButton } from './menu'
|
import { MenuButton } from './menu'
|
||||||
import { ProfileSummary } from './profile-menu'
|
import { ProfileSummary } from './profile-menu'
|
||||||
|
@ -27,7 +29,11 @@ import { Row } from '../layout/row'
|
||||||
import NotificationsIcon from 'web/components/notifications-icon'
|
import NotificationsIcon from 'web/components/notifications-icon'
|
||||||
import React, { useEffect, useState } from 'react'
|
import React, { useEffect, useState } from 'react'
|
||||||
import { IS_PRIVATE_MANIFOLD } from 'common/envs/constants'
|
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 { trackCallback, withTracking } from 'web/lib/service/analytics'
|
||||||
|
import { Group } from 'common/group'
|
||||||
|
|
||||||
// Create an icon from the url of an image
|
// Create an icon from the url of an image
|
||||||
function IconFromUrl(url: string): React.ComponentType<{ className?: string }> {
|
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 },
|
{ name: 'Get M$', href: '/add-funds', icon: CashIcon },
|
||||||
...signedOutMobileNavigation,
|
...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 (
|
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">
|
<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"
|
className="-ml-1 mr-3 h-6 w-6 flex-shrink-0 text-gray-400 group-hover:text-gray-500"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
<span className="truncate">More</span>
|
<span className="truncate">{text}</span>
|
||||||
|
{children}
|
||||||
</a>
|
</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 }) {
|
export default function Sidebar(props: { className?: string }) {
|
||||||
const { className } = props
|
const { className } = props
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
@ -195,18 +219,16 @@ export default function Sidebar(props: { className?: string }) {
|
||||||
|
|
||||||
const user = useUser()
|
const user = useUser()
|
||||||
const mustWaitForFreeMarketStatus = useHasCreatedContractToday(user)
|
const mustWaitForFreeMarketStatus = useHasCreatedContractToday(user)
|
||||||
const navigationOptions =
|
const navigationOptions = !user
|
||||||
user === null
|
|
||||||
? signedOutNavigation
|
? signedOutNavigation
|
||||||
: getNavigation(user?.username || 'error')
|
: getNavigation(user?.username || 'error')
|
||||||
const mobileNavigationOptions =
|
const mobileNavigationOptions = !user
|
||||||
user === null ? signedOutMobileNavigation : mobileNavigation
|
? signedOutMobileNavigation
|
||||||
|
: signedInMobileNavigation
|
||||||
const gradient =
|
const memberItems = (useMemberGroups(user) ?? []).map((group: Group) => ({
|
||||||
'from-indigo-500 to-blue-500 hover:from-indigo-700 hover:to-blue-700'
|
name: group.name,
|
||||||
|
href: groupPath(group.slug),
|
||||||
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'
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav aria-label="Sidebar" className={className}>
|
<nav aria-label="Sidebar" className={className}>
|
||||||
|
@ -218,9 +240,23 @@ export default function Sidebar(props: { className?: string }) {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="space-y-1 lg:hidden">
|
<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) => (
|
{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 && (
|
{user && (
|
||||||
<MenuButton
|
<MenuButton
|
||||||
|
@ -237,35 +273,37 @@ export default function Sidebar(props: { className?: string }) {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="hidden space-y-1 lg:block">
|
<div className="hidden space-y-1 lg:block">
|
||||||
{navigationOptions.map((item) => (
|
{navigationOptions.map((item) =>
|
||||||
<SidebarItem key={item.name} item={item} currentPage={currentPage} />
|
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
|
<MenuButton
|
||||||
menuItems={getMoreNavigation(user)}
|
menuItems={getMoreNavigation(user)}
|
||||||
buttonContent={<MoreButton />}
|
buttonContent={<MoreButton />}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<CreateQuestionButton user={user} />
|
||||||
<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>
|
|
||||||
|
|
||||||
{user &&
|
{user &&
|
||||||
mustWaitForFreeMarketStatus != 'loading' &&
|
mustWaitForFreeMarketStatus != 'loading' &&
|
||||||
|
|
|
@ -6,7 +6,6 @@ import { Col } from './layout/col'
|
||||||
import { Row } from './layout/row'
|
import { Row } from './layout/row'
|
||||||
import { TagsList } from './tags-list'
|
import { TagsList } from './tags-list'
|
||||||
import { MAX_TAG_LENGTH } from 'common/contract'
|
import { MAX_TAG_LENGTH } from 'common/contract'
|
||||||
import { track } from 'web/lib/service/analytics'
|
|
||||||
|
|
||||||
export function TagsInput(props: { contract: Contract; className?: string }) {
|
export function TagsInput(props: { contract: Contract; className?: string }) {
|
||||||
const { contract, className } = props
|
const { contract, className } = props
|
||||||
|
@ -25,7 +24,6 @@ export function TagsInput(props: { contract: Contract; className?: string }) {
|
||||||
})
|
})
|
||||||
setIsSubmitting(false)
|
setIsSubmitting(false)
|
||||||
setTagText('')
|
setTagText('')
|
||||||
track('save tags')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -55,25 +53,3 @@ export function TagsInput(props: { contract: Contract; className?: string }) {
|
||||||
</Col>
|
</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 { FollowersButton, FollowingButton } from './following-button'
|
||||||
import { useFollows } from 'web/hooks/use-follows'
|
import { useFollows } from 'web/hooks/use-follows'
|
||||||
import { FollowButton } from './follow-button'
|
import { FollowButton } from './follow-button'
|
||||||
|
import { useRouter } from 'next/router'
|
||||||
|
|
||||||
export function UserLink(props: {
|
export function UserLink(props: {
|
||||||
name: string
|
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()
|
const JUNE_1_2022 = new Date('2022-06-01T00:00:00.000Z').valueOf()
|
||||||
|
|
||||||
export function UserPage(props: {
|
export function UserPage(props: {
|
||||||
user: User
|
user: User
|
||||||
currentUser?: User
|
currentUser?: User
|
||||||
defaultTabTitle?: 'markets' | 'comments' | 'bets'
|
defaultTabTitle?: string | undefined
|
||||||
}) {
|
}) {
|
||||||
const { user, currentUser, defaultTabTitle } = props
|
const { user, currentUser, defaultTabTitle } = props
|
||||||
const isCurrentUser = user.id === currentUser?.id
|
const isCurrentUser = user.id === currentUser?.id
|
||||||
|
@ -66,6 +67,7 @@ export function UserPage(props: {
|
||||||
const [commentsByContract, setCommentsByContract] = useState<
|
const [commentsByContract, setCommentsByContract] = useState<
|
||||||
Map<Contract, Comment[]> | 'loading'
|
Map<Contract, Comment[]> | 'loading'
|
||||||
>('loading')
|
>('loading')
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!user) return
|
if (!user) return
|
||||||
|
@ -74,12 +76,15 @@ export function UserPage(props: {
|
||||||
getUserBets(user.id, { includeRedemptions: false }).then(setUsersBets)
|
getUserBets(user.id, { includeRedemptions: false }).then(setUsersBets)
|
||||||
}, [user])
|
}, [user])
|
||||||
|
|
||||||
|
// TODO: display comments on groups
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const uniqueContractIds = uniq(
|
const uniqueContractIds = uniq(
|
||||||
usersComments.map((comment) => comment.contractId)
|
usersComments.map((comment) => comment.contractId)
|
||||||
)
|
)
|
||||||
Promise.all(
|
Promise.all(
|
||||||
uniqueContractIds.map((contractId) => getContractFromId(contractId))
|
uniqueContractIds.map(
|
||||||
|
(contractId) => contractId && getContractFromId(contractId)
|
||||||
|
)
|
||||||
).then((contracts) => {
|
).then((contracts) => {
|
||||||
const commentsByContract = new Map<Contract, Comment[]>()
|
const commentsByContract = new Map<Contract, Comment[]>()
|
||||||
contracts.forEach((contract) => {
|
contracts.forEach((contract) => {
|
||||||
|
@ -225,13 +230,17 @@ export function UserPage(props: {
|
||||||
{usersContracts !== 'loading' && commentsByContract != 'loading' ? (
|
{usersContracts !== 'loading' && commentsByContract != 'loading' ? (
|
||||||
<Tabs
|
<Tabs
|
||||||
className={'pb-2 pt-1 '}
|
className={'pb-2 pt-1 '}
|
||||||
defaultIndex={TAB_IDS.indexOf(defaultTabTitle || 'markets')}
|
defaultIndex={
|
||||||
|
defaultTabTitle ? TAB_IDS.indexOf(defaultTabTitle) : 0
|
||||||
|
}
|
||||||
onClick={(tabName) => {
|
onClick={(tabName) => {
|
||||||
const tabId = tabName.toLowerCase()
|
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
|
// 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`
|
// 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={[
|
tabs={[
|
||||||
{
|
{
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import {
|
import {
|
||||||
Comment,
|
Comment,
|
||||||
listenForComments,
|
listenForCommentsOnContract,
|
||||||
listenForRecentComments,
|
listenForRecentComments,
|
||||||
} from 'web/lib/firebase/comments'
|
} from 'web/lib/firebase/comments'
|
||||||
|
|
||||||
|
@ -9,7 +9,7 @@ export const useComments = (contractId: string) => {
|
||||||
const [comments, setComments] = useState<Comment[] | undefined>()
|
const [comments, setComments] = useState<Comment[] | undefined>()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (contractId) return listenForComments(contractId, setComments)
|
if (contractId) return listenForCommentsOnContract(contractId, setComments)
|
||||||
}, [contractId])
|
}, [contractId])
|
||||||
|
|
||||||
return comments
|
return comments
|
||||||
|
|
|
@ -9,7 +9,6 @@ import {
|
||||||
listenForInactiveContracts,
|
listenForInactiveContracts,
|
||||||
listenForNewContracts,
|
listenForNewContracts,
|
||||||
} from 'web/lib/firebase/contracts'
|
} from 'web/lib/firebase/contracts'
|
||||||
import { listenForTaggedContracts } from 'web/lib/firebase/folds'
|
|
||||||
|
|
||||||
export const useContracts = () => {
|
export const useContracts = () => {
|
||||||
const [contracts, setContracts] = useState<Contract[] | undefined>()
|
const [contracts, setContracts] = useState<Contract[] | undefined>()
|
||||||
|
@ -50,21 +49,6 @@ export const useInactiveContracts = () => {
|
||||||
return contracts
|
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 = () => {
|
export const useHotContracts = () => {
|
||||||
const [hotContracts, setHotContracts] = useState<Contract[] | undefined>()
|
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'
|
| 'resolve-date'
|
||||||
| 'last-updated'
|
| '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?: {
|
export function useInitialQueryAndSort(options?: {
|
||||||
defaultSort: Sort
|
defaultSort: Sort
|
||||||
shouldLoadFromStorage?: boolean
|
shouldLoadFromStorage?: boolean
|
||||||
|
|
|
@ -4,10 +4,12 @@ import { V2CloudFunction } from 'common/envs/prod'
|
||||||
|
|
||||||
export class APIError extends Error {
|
export class APIError extends Error {
|
||||||
code: number
|
code: number
|
||||||
constructor(code: number, message: string) {
|
details?: string
|
||||||
|
constructor(code: number, message: string, details?: string) {
|
||||||
super(message)
|
super(message)
|
||||||
this.code = code
|
this.code = code
|
||||||
this.name = 'APIError'
|
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) => {
|
return await fetch(req).then(async (resp) => {
|
||||||
const json = (await resp.json()) as { [k: string]: any }
|
const json = (await resp.json()) as { [k: string]: any }
|
||||||
if (!resp.ok) {
|
if (!resp.ok) {
|
||||||
throw new APIError(resp.status, json?.message)
|
throw new APIError(resp.status, json?.message, json?.details)
|
||||||
}
|
}
|
||||||
return json
|
return json
|
||||||
})
|
})
|
||||||
|
@ -58,3 +60,7 @@ export function sellShares(params: any) {
|
||||||
export function sellBet(params: any) {
|
export function sellBet(params: any) {
|
||||||
return call(getFunctionUrl('sellbet'), 'POST', params)
|
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 const MAX_COMMENT_LENGTH = 10000
|
||||||
|
|
||||||
export async function createComment(
|
export async function createCommentOnContract(
|
||||||
contractId: string,
|
contractId: string,
|
||||||
text: string,
|
text: string,
|
||||||
commenter: User,
|
commenter: User,
|
||||||
|
@ -52,10 +52,39 @@ export async function createComment(
|
||||||
})
|
})
|
||||||
return await setDoc(ref, comment)
|
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) {
|
function getCommentsCollection(contractId: string) {
|
||||||
return collection(db, 'contracts', contractId, 'comments')
|
return collection(db, 'contracts', contractId, 'comments')
|
||||||
}
|
}
|
||||||
|
function getCommentsOnGroupCollection(groupId: string) {
|
||||||
|
return collection(db, 'groups', groupId, 'comments')
|
||||||
|
}
|
||||||
|
|
||||||
export async function listAllComments(contractId: string) {
|
export async function listAllComments(contractId: string) {
|
||||||
const comments = await getValues<Comment>(getCommentsCollection(contractId))
|
const comments = await getValues<Comment>(getCommentsCollection(contractId))
|
||||||
|
@ -63,7 +92,7 @@ export async function listAllComments(contractId: string) {
|
||||||
return comments
|
return comments
|
||||||
}
|
}
|
||||||
|
|
||||||
export function listenForComments(
|
export function listenForCommentsOnContract(
|
||||||
contractId: string,
|
contractId: string,
|
||||||
setComments: (comments: Comment[]) => void
|
setComments: (comments: Comment[]) => void
|
||||||
) {
|
) {
|
||||||
|
@ -75,16 +104,17 @@ export function listenForComments(
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
export function listenForCommentsOnGroup(
|
||||||
// Return a map of betId -> comment
|
groupId: string,
|
||||||
export function mapCommentsByBetId(comments: Comment[]) {
|
setComments: (comments: Comment[]) => void
|
||||||
const map: Record<string, Comment> = {}
|
) {
|
||||||
for (const comment of comments) {
|
return listenForValues<Comment>(
|
||||||
if (comment.betId) {
|
getCommentsOnGroupCollection(groupId),
|
||||||
map[comment.betId] = comment
|
(comments) => {
|
||||||
|
comments.sort((c1, c2) => c1.createdTime - c2.createdTime)
|
||||||
|
setComments(comments)
|
||||||
}
|
}
|
||||||
}
|
)
|
||||||
return map
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const DAY_IN_MS = 24 * 60 * 60 * 1000
|
const DAY_IN_MS = 24 * 60 * 60 * 1000
|
||||||
|
|
|
@ -166,6 +166,18 @@ export function listenForContracts(
|
||||||
return listenForValues<Contract>(q, setContracts)
|
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(
|
const activeContractsQuery = query(
|
||||||
contractCollection,
|
contractCollection,
|
||||||
where('isResolved', '==', false),
|
where('isResolved', '==', false),
|
||||||
|
|
|
@ -1,5 +1,4 @@
|
||||||
import { httpsCallable } from 'firebase/functions'
|
import { httpsCallable } from 'firebase/functions'
|
||||||
import { Fold } from 'common/fold'
|
|
||||||
import { Txn } from 'common/txn'
|
import { Txn } from 'common/txn'
|
||||||
import { User } from 'common/user'
|
import { User } from 'common/user'
|
||||||
import { randomString } from 'common/util/random'
|
import { randomString } from 'common/util/random'
|
||||||
|
@ -15,11 +14,6 @@ export const withdrawLiquidity = cloudFunction<
|
||||||
{ status: 'error' | 'success'; userShares: { [outcome: string]: number } }
|
{ status: 'error' | 'success'; userShares: { [outcome: string]: number } }
|
||||||
>('withdrawLiquidity')
|
>('withdrawLiquidity')
|
||||||
|
|
||||||
export const createFold = cloudFunction<
|
|
||||||
{ name: string; about: string; tags: string[] },
|
|
||||||
{ status: 'error' | 'success'; message?: string; fold?: Fold }
|
|
||||||
>('createFold')
|
|
||||||
|
|
||||||
export const transact = cloudFunction<
|
export const transact = cloudFunction<
|
||||||
Omit<Txn, 'id' | 'createdTime'>,
|
Omit<Txn, 'id' | 'createdTime'>,
|
||||||
{ status: 'error' | 'success'; message?: string; txn?: Txn }
|
{ 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 Custom404 from '../404'
|
||||||
import { useTracking } from 'web/hooks/use-tracking'
|
import { useTracking } from 'web/hooks/use-tracking'
|
||||||
|
|
||||||
export default function UserProfile(props: {
|
export default function UserProfile() {
|
||||||
tab?: 'markets' | 'comments' | 'bets'
|
|
||||||
}) {
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const [user, setUser] = useState<User | null | 'loading'>('loading')
|
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(() => {
|
useEffect(() => {
|
||||||
if (username) {
|
if (username) {
|
||||||
getUserByUsername(username).then(setUser)
|
getUserByUsername(username).then(setUser)
|
||||||
|
@ -24,13 +24,13 @@ export default function UserProfile(props: {
|
||||||
|
|
||||||
useTracking('view user profile', { username })
|
useTracking('view user profile', { username })
|
||||||
|
|
||||||
if (user === 'loading') return <></>
|
if (user === 'loading') return <div />
|
||||||
|
|
||||||
return user ? (
|
return user ? (
|
||||||
<UserPage
|
<UserPage
|
||||||
user={user}
|
user={user}
|
||||||
currentUser={currentUser || undefined}
|
currentUser={currentUser || undefined}
|
||||||
defaultTabTitle={props.tab}
|
defaultTabTitle={tab}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Custom404 />
|
<Custom404 />
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import router from 'next/router'
|
import router, { useRouter } from 'next/router'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
|
@ -19,16 +19,22 @@ import {
|
||||||
import { formatMoney } from 'common/util/format'
|
import { formatMoney } from 'common/util/format'
|
||||||
import { useHasCreatedContractToday } from 'web/hooks/use-has-created-contract-today'
|
import { useHasCreatedContractToday } from 'web/hooks/use-has-created-contract-today'
|
||||||
import { removeUndefinedProps } from 'common/util/object'
|
import { removeUndefinedProps } from 'common/util/object'
|
||||||
import { CATEGORIES } from 'common/categories'
|
|
||||||
import { ChoicesToggleGroup } from 'web/components/choices-toggle-group'
|
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 { useTracking } from 'web/hooks/use-tracking'
|
||||||
import { useWarnUnsavedChanges } from 'web/hooks/use-warn-unsaved-changes'
|
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() {
|
export default function Create() {
|
||||||
const [question, setQuestion] = useState('')
|
const [question, setQuestion] = useState('')
|
||||||
|
// get query params:
|
||||||
|
const router = useRouter()
|
||||||
|
const { groupId } = router.query as { groupId: string }
|
||||||
useTracking('view create page')
|
useTracking('view create page')
|
||||||
|
if (!router.isReady) return <div />
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Page>
|
<Page>
|
||||||
|
@ -53,7 +59,7 @@ export default function Create() {
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
<Spacer h={6} />
|
<Spacer h={6} />
|
||||||
<NewContract question={question} />
|
<NewContract question={question} groupId={groupId} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Page>
|
</Page>
|
||||||
|
@ -61,8 +67,8 @@ export default function Create() {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Allow user to create a new contract
|
// Allow user to create a new contract
|
||||||
export function NewContract(props: { question: string }) {
|
export function NewContract(props: { question: string; groupId?: string }) {
|
||||||
const { question } = props
|
const { question, groupId } = props
|
||||||
const creator = useUser()
|
const creator = useUser()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
@ -74,11 +80,17 @@ export function NewContract(props: { question: string }) {
|
||||||
const [minString, setMinString] = useState('')
|
const [minString, setMinString] = useState('')
|
||||||
const [maxString, setMaxString] = useState('')
|
const [maxString, setMaxString] = useState('')
|
||||||
const [description, setDescription] = useState('')
|
const [description, setDescription] = useState('')
|
||||||
|
|
||||||
const [category, setCategory] = useState<string>('')
|
|
||||||
// const [tagText, setTagText] = useState<string>(tag ?? '')
|
// const [tagText, setTagText] = useState<string>(tag ?? '')
|
||||||
// const tags = parseWordsAsTags(tagText)
|
// 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 [ante, _setAnte] = useState(FIXED_ANTE)
|
||||||
|
|
||||||
const mustWaitForDailyFreeMarketStatus = useHasCreatedContractToday(creator)
|
const mustWaitForDailyFreeMarketStatus = useHasCreatedContractToday(creator)
|
||||||
|
@ -100,6 +112,11 @@ export function NewContract(props: { question: string }) {
|
||||||
const [closeHoursMinutes, setCloseHoursMinutes] = useState<string>('23:59')
|
const [closeHoursMinutes, setCloseHoursMinutes] = useState<string>('23:59')
|
||||||
const [marketInfoText, setMarketInfoText] = useState('')
|
const [marketInfoText, setMarketInfoText] = useState('')
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
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
|
const closeTime = closeDate
|
||||||
? dayjs(`${closeDate}T${closeHoursMinutes}`).valueOf()
|
? dayjs(`${closeDate}T${closeHoursMinutes}`).valueOf()
|
||||||
|
@ -145,7 +162,7 @@ export function NewContract(props: { question: string }) {
|
||||||
if (!creator || !isValid) return
|
if (!creator || !isValid) return
|
||||||
|
|
||||||
setIsSubmitting(true)
|
setIsSubmitting(true)
|
||||||
|
// TODO: add contract id to the group contractIds
|
||||||
try {
|
try {
|
||||||
const result = await createMarket(
|
const result = await createMarket(
|
||||||
removeUndefinedProps({
|
removeUndefinedProps({
|
||||||
|
@ -155,18 +172,22 @@ export function NewContract(props: { question: string }) {
|
||||||
initialProb,
|
initialProb,
|
||||||
ante,
|
ante,
|
||||||
closeTime,
|
closeTime,
|
||||||
tags: category ? [category] : undefined,
|
|
||||||
min,
|
min,
|
||||||
max,
|
max,
|
||||||
|
groupId: selectedGroup?.id,
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
||||||
track('create market', {
|
track('create market', {
|
||||||
slug: result.slug,
|
slug: result.slug,
|
||||||
initialProb,
|
initialProb,
|
||||||
category,
|
selectedGroup: selectedGroup?.id,
|
||||||
isFree,
|
isFree,
|
||||||
})
|
})
|
||||||
|
if (result && selectedGroup) {
|
||||||
|
await updateGroup(selectedGroup, {
|
||||||
|
contractIds: [...selectedGroup.contractIds, result.id],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
await router.push(contractPath(result as Contract))
|
await router.push(contractPath(result as Contract))
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
@ -245,17 +266,20 @@ export function NewContract(props: { question: string }) {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="form-control max-w-sm items-start">
|
<div className="form-control max-w-[265px] items-start">
|
||||||
<label className="label gap-2">
|
<label className="label gap-2">
|
||||||
<span className="mb-1">Category</span>
|
<span className="mb-1">Category</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<select
|
<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}
|
value={category}
|
||||||
onChange={(e) => setCategory(e.currentTarget.value ?? '')}
|
onChange={(e) => setCategory(e.currentTarget.value ?? '')}
|
||||||
>
|
>
|
||||||
<option value={''}>(none)</option>
|
<option value={''}>None</option>
|
||||||
{Object.entries(CATEGORIES).map(([id, name]) => (
|
{Object.entries(CATEGORIES).map(([id, name]) => (
|
||||||
<option key={id} value={id}>
|
<option key={id} value={id}>
|
||||||
{name}
|
{name}
|
||||||
|
@ -264,6 +288,15 @@ export function NewContract(props: { question: string }) {
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className={'mt-2'}>
|
||||||
|
<GroupSelector
|
||||||
|
selectedGroup={selectedGroup}
|
||||||
|
setSelectedGroup={setSelectedGroup}
|
||||||
|
creator={creator}
|
||||||
|
showSelector={showGroupSelector}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Spacer h={6} />
|
<Spacer h={6} />
|
||||||
|
|
||||||
<div className="form-control mb-1 items-start">
|
<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 { CheckIcon, XIcon } from '@heroicons/react/outline'
|
||||||
import toast from 'react-hot-toast'
|
import toast from 'react-hot-toast'
|
||||||
import { formatMoney } from 'common/util/format'
|
import { formatMoney } from 'common/util/format'
|
||||||
|
import { groupPath } from 'web/lib/firebase/groups'
|
||||||
|
|
||||||
export default function Notifications() {
|
export default function Notifications() {
|
||||||
const user = useUser()
|
const user = useUser()
|
||||||
|
@ -530,6 +531,8 @@ function NotificationItem(props: {
|
||||||
sourceContractTitle,
|
sourceContractTitle,
|
||||||
sourceContractCreatorUsername,
|
sourceContractCreatorUsername,
|
||||||
sourceContractSlug,
|
sourceContractSlug,
|
||||||
|
sourceSlug,
|
||||||
|
sourceTitle,
|
||||||
} = notification
|
} = notification
|
||||||
|
|
||||||
const [defaultNotificationText, setDefaultNotificationText] =
|
const [defaultNotificationText, setDefaultNotificationText] =
|
||||||
|
@ -595,6 +598,7 @@ function NotificationItem(props: {
|
||||||
|
|
||||||
function getSourceUrl() {
|
function getSourceUrl() {
|
||||||
if (sourceType === 'follow') return `/${sourceUserUsername}`
|
if (sourceType === 'follow') return `/${sourceUserUsername}`
|
||||||
|
if (sourceType === 'group' && sourceSlug) return `${groupPath(sourceSlug)}`
|
||||||
if (sourceContractCreatorUsername && sourceContractSlug)
|
if (sourceContractCreatorUsername && sourceContractSlug)
|
||||||
return `/${sourceContractCreatorUsername}/${sourceContractSlug}#${getSourceIdForLinkComponent(
|
return `/${sourceContractCreatorUsername}/${sourceContractSlug}#${getSourceIdForLinkComponent(
|
||||||
sourceId ?? ''
|
sourceId ?? ''
|
||||||
|
@ -722,13 +726,15 @@ function NotificationItem(props: {
|
||||||
href={
|
href={
|
||||||
sourceContractCreatorUsername
|
sourceContractCreatorUsername
|
||||||
? `/${sourceContractCreatorUsername}/${sourceContractSlug}`
|
? `/${sourceContractCreatorUsername}/${sourceContractSlug}`
|
||||||
|
: sourceType === 'group' && sourceSlug
|
||||||
|
? `${groupPath(sourceSlug)}`
|
||||||
: `/${contract?.creatorUsername}/${contract?.slug}`
|
: `/${contract?.creatorUsername}/${contract?.slug}`
|
||||||
}
|
}
|
||||||
className={
|
className={
|
||||||
'ml-1 font-bold hover:underline hover:decoration-indigo-400 hover:decoration-2'
|
'ml-1 font-bold hover:underline hover:decoration-indigo-400 hover:decoration-2'
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{contract?.question || sourceContractTitle}
|
{contract?.question || sourceContractTitle || sourceTitle}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
@ -736,8 +742,8 @@ function NotificationItem(props: {
|
||||||
</div>
|
</div>
|
||||||
{sourceId && sourceContractSlug && sourceContractCreatorUsername ? (
|
{sourceId && sourceContractSlug && sourceContractCreatorUsername ? (
|
||||||
<CopyLinkDateTimeComponent
|
<CopyLinkDateTimeComponent
|
||||||
contractCreatorUsername={sourceContractCreatorUsername}
|
prefix={sourceContractCreatorUsername}
|
||||||
contractSlug={sourceContractSlug}
|
slug={sourceContractSlug}
|
||||||
createdTime={createdTime}
|
createdTime={createdTime}
|
||||||
elementId={getSourceIdForLinkComponent(sourceId)}
|
elementId={getSourceIdForLinkComponent(sourceId)}
|
||||||
className={'-mx-1 inline-flex sm:inline-block'}
|
className={'-mx-1 inline-flex sm:inline-block'}
|
||||||
|
@ -877,6 +883,9 @@ function getReasonForShowingNotification(
|
||||||
case 'liquidity':
|
case 'liquidity':
|
||||||
reasonText = 'added liquidity to your question'
|
reasonText = 'added liquidity to your question'
|
||||||
break
|
break
|
||||||
|
case 'group':
|
||||||
|
reasonText = 'added you to the group'
|
||||||
|
break
|
||||||
default:
|
default:
|
||||||
reasonText = ''
|
reasonText = ''
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue
Block a user