Members and contracts now subcollections of groups (#847)
* Members and contracts now documents * undo loans change? * Handle closed group * Slight refactoring * Don't allow modification of private groups contracts * Add back in numMembers * Update group field names * Update firestore rules * Update firestore rules * Handle updated groups * update start numbers * Lint * Lint
This commit is contained in:
parent
2f53cef36f
commit
cf508fd8b6
|
@ -6,14 +6,16 @@ export type Group = {
|
||||||
creatorId: string // User id
|
creatorId: string // User id
|
||||||
createdTime: number
|
createdTime: number
|
||||||
mostRecentActivityTime: number
|
mostRecentActivityTime: number
|
||||||
memberIds: string[] // User ids
|
|
||||||
anyoneCanJoin: boolean
|
anyoneCanJoin: boolean
|
||||||
contractIds: string[]
|
totalContracts: number
|
||||||
|
totalMembers: number
|
||||||
aboutPostId?: string
|
aboutPostId?: string
|
||||||
chatDisabled?: boolean
|
chatDisabled?: boolean
|
||||||
mostRecentChatActivityTime?: number
|
|
||||||
mostRecentContractAddedTime?: number
|
mostRecentContractAddedTime?: number
|
||||||
|
/** @deprecated - members and contracts now stored as subcollections*/
|
||||||
|
memberIds?: string[] // Deprecated
|
||||||
|
/** @deprecated - members and contracts now stored as subcollections*/
|
||||||
|
contractIds?: string[] // Deprecated
|
||||||
}
|
}
|
||||||
export const MAX_GROUP_NAME_LENGTH = 75
|
export const MAX_GROUP_NAME_LENGTH = 75
|
||||||
export const MAX_ABOUT_LENGTH = 140
|
export const MAX_ABOUT_LENGTH = 140
|
||||||
|
|
|
@ -160,25 +160,40 @@ service cloud.firestore {
|
||||||
.hasOnly(['isSeen', 'viewTime']);
|
.hasOnly(['isSeen', 'viewTime']);
|
||||||
}
|
}
|
||||||
|
|
||||||
match /groups/{groupId} {
|
match /{somePath=**}/groupMembers/{memberId} {
|
||||||
|
allow read;
|
||||||
|
}
|
||||||
|
|
||||||
|
match /{somePath=**}/groupContracts/{contractId} {
|
||||||
|
allow read;
|
||||||
|
}
|
||||||
|
|
||||||
|
match /groups/{groupId} {
|
||||||
allow read;
|
allow read;
|
||||||
allow update: if (request.auth.uid == resource.data.creatorId || isAdmin())
|
allow update: if (request.auth.uid == resource.data.creatorId || isAdmin())
|
||||||
&& request.resource.data.diff(resource.data)
|
&& request.resource.data.diff(resource.data)
|
||||||
.affectedKeys()
|
.affectedKeys()
|
||||||
.hasOnly(['name', 'about', 'contractIds', 'memberIds', 'anyoneCanJoin', 'aboutPostId' ]);
|
.hasOnly(['name', 'about', 'anyoneCanJoin', 'aboutPostId' ]);
|
||||||
allow update: if (request.auth.uid in resource.data.memberIds || resource.data.anyoneCanJoin)
|
|
||||||
&& request.resource.data.diff(resource.data)
|
|
||||||
.affectedKeys()
|
|
||||||
.hasOnly([ 'contractIds', 'memberIds' ]);
|
|
||||||
allow delete: if request.auth.uid == resource.data.creatorId;
|
allow delete: if request.auth.uid == resource.data.creatorId;
|
||||||
|
|
||||||
function isMember() {
|
match /groupContracts/{contractId} {
|
||||||
return request.auth.uid in get(/databases/$(database)/documents/groups/$(groupId)).data.memberIds;
|
allow write: if isGroupMember() || request.auth.uid == get(/databases/$(database)/documents/groups/$(groupId)).data.creatorId
|
||||||
|
}
|
||||||
|
|
||||||
|
match /groupMembers/{memberId}{
|
||||||
|
allow create: if request.auth.uid == get(/databases/$(database)/documents/groups/$(groupId)).data.creatorId || (request.auth.uid == request.resource.data.userId && get(/databases/$(database)/documents/groups/$(groupId)).data.anyoneCanJoin);
|
||||||
|
allow delete: if request.auth.uid == resource.data.userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isGroupMember() {
|
||||||
|
return exists(/databases/$(database)/documents/groups/$(groupId)/groupMembers/$(request.auth.uid));
|
||||||
}
|
}
|
||||||
|
|
||||||
match /comments/{commentId} {
|
match /comments/{commentId} {
|
||||||
allow read;
|
allow read;
|
||||||
allow create: if request.auth != null && commentMatchesUser(request.auth.uid, request.resource.data) && isMember();
|
allow create: if request.auth != null && commentMatchesUser(request.auth.uid, request.resource.data) && isGroupMember();
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
match /posts/{postId} {
|
match /posts/{postId} {
|
||||||
|
|
|
@ -58,13 +58,23 @@ export const creategroup = newEndpoint({}, async (req, auth) => {
|
||||||
createdTime: Date.now(),
|
createdTime: Date.now(),
|
||||||
mostRecentActivityTime: Date.now(),
|
mostRecentActivityTime: Date.now(),
|
||||||
// TODO: allow users to add contract ids on group creation
|
// TODO: allow users to add contract ids on group creation
|
||||||
contractIds: [],
|
|
||||||
anyoneCanJoin,
|
anyoneCanJoin,
|
||||||
memberIds,
|
totalContracts: 0,
|
||||||
|
totalMembers: memberIds.length,
|
||||||
}
|
}
|
||||||
|
|
||||||
await groupRef.create(group)
|
await groupRef.create(group)
|
||||||
|
|
||||||
|
// create a GroupMemberDoc for each member
|
||||||
|
await Promise.all(
|
||||||
|
memberIds.map((memberId) =>
|
||||||
|
groupRef.collection('groupMembers').doc(memberId).create({
|
||||||
|
userId: memberId,
|
||||||
|
createdTime: Date.now(),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
return { status: 'success', group: group }
|
return { status: 'success', group: group }
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
@ -155,8 +155,14 @@ export const createmarket = newEndpoint({}, async (req, auth) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
group = groupDoc.data() as Group
|
group = groupDoc.data() as Group
|
||||||
|
const groupMembersSnap = await firestore
|
||||||
|
.collection(`groups/${groupId}/groupMembers`)
|
||||||
|
.get()
|
||||||
|
const groupMemberDocs = groupMembersSnap.docs.map(
|
||||||
|
(doc) => doc.data() as { userId: string; createdTime: number }
|
||||||
|
)
|
||||||
if (
|
if (
|
||||||
!group.memberIds.includes(user.id) &&
|
!groupMemberDocs.map((m) => m.userId).includes(user.id) &&
|
||||||
!group.anyoneCanJoin &&
|
!group.anyoneCanJoin &&
|
||||||
group.creatorId !== user.id
|
group.creatorId !== user.id
|
||||||
) {
|
) {
|
||||||
|
@ -227,11 +233,20 @@ export const createmarket = newEndpoint({}, async (req, auth) => {
|
||||||
await contractRef.create(contract)
|
await contractRef.create(contract)
|
||||||
|
|
||||||
if (group != null) {
|
if (group != null) {
|
||||||
if (!group.contractIds.includes(contractRef.id)) {
|
const groupContractsSnap = await firestore
|
||||||
|
.collection(`groups/${groupId}/groupContracts`)
|
||||||
|
.get()
|
||||||
|
const groupContracts = groupContractsSnap.docs.map(
|
||||||
|
(doc) => doc.data() as { contractId: string; createdTime: number }
|
||||||
|
)
|
||||||
|
if (!groupContracts.map((c) => c.contractId).includes(contractRef.id)) {
|
||||||
await createGroupLinks(group, [contractRef.id], auth.uid)
|
await createGroupLinks(group, [contractRef.id], auth.uid)
|
||||||
const groupDocRef = firestore.collection('groups').doc(group.id)
|
const groupContractRef = firestore
|
||||||
groupDocRef.update({
|
.collection(`groups/${groupId}/groupContracts`)
|
||||||
contractIds: uniq([...group.contractIds, contractRef.id]),
|
.doc(contract.id)
|
||||||
|
await groupContractRef.set({
|
||||||
|
contractId: contract.id,
|
||||||
|
createdTime: Date.now(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,6 +1,5 @@
|
||||||
import * as admin from 'firebase-admin'
|
import * as admin from 'firebase-admin'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { uniq } from 'lodash'
|
|
||||||
|
|
||||||
import { PrivateUser, User } from '../../common/user'
|
import { PrivateUser, User } from '../../common/user'
|
||||||
import { getUser, getUserByUsername, getValues } from './utils'
|
import { getUser, getUserByUsername, getValues } from './utils'
|
||||||
|
@ -17,7 +16,7 @@ import {
|
||||||
|
|
||||||
import { track } from './analytics'
|
import { track } from './analytics'
|
||||||
import { APIError, newEndpoint, validate } from './api'
|
import { APIError, newEndpoint, validate } from './api'
|
||||||
import { Group, NEW_USER_GROUP_SLUGS } from '../../common/group'
|
import { Group } from '../../common/group'
|
||||||
import { SUS_STARTING_BALANCE, STARTING_BALANCE } from '../../common/economy'
|
import { SUS_STARTING_BALANCE, STARTING_BALANCE } from '../../common/economy'
|
||||||
|
|
||||||
const bodySchema = z.object({
|
const bodySchema = z.object({
|
||||||
|
@ -117,23 +116,8 @@ const addUserToDefaultGroups = async (user: User) => {
|
||||||
firestore.collection('groups').where('slug', '==', slug)
|
firestore.collection('groups').where('slug', '==', slug)
|
||||||
)
|
)
|
||||||
await firestore
|
await firestore
|
||||||
.collection('groups')
|
.collection(`groups/${groups[0].id}/groupMembers`)
|
||||||
.doc(groups[0].id)
|
.doc(user.id)
|
||||||
.update({
|
.set({ userId: user.id, createdTime: Date.now() })
|
||||||
memberIds: uniq(groups[0].memberIds.concat(user.id)),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const slug of NEW_USER_GROUP_SLUGS) {
|
|
||||||
const groups = await getValues<Group>(
|
|
||||||
firestore.collection('groups').where('slug', '==', slug)
|
|
||||||
)
|
|
||||||
const group = groups[0]
|
|
||||||
await firestore
|
|
||||||
.collection('groups')
|
|
||||||
.doc(group.id)
|
|
||||||
.update({
|
|
||||||
memberIds: uniq(group.memberIds.concat(user.id)),
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -21,9 +21,7 @@ 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-update-group'
|
||||||
export * from './on-create-group'
|
|
||||||
export * from './on-update-user'
|
export * from './on-update-user'
|
||||||
export * from './on-create-comment-on-group'
|
|
||||||
export * from './on-create-txn'
|
export * from './on-create-txn'
|
||||||
export * from './on-delete-group'
|
export * from './on-delete-group'
|
||||||
export * from './score-contracts'
|
export * from './score-contracts'
|
||||||
|
|
|
@ -1,46 +0,0 @@
|
||||||
import * as functions from 'firebase-functions'
|
|
||||||
import { GroupComment } from '../../common/comment'
|
|
||||||
import * as admin from 'firebase-admin'
|
|
||||||
import { Group } from '../../common/group'
|
|
||||||
import { User } from '../../common/user'
|
|
||||||
import { createGroupCommentNotification } from './create-notification'
|
|
||||||
const firestore = admin.firestore()
|
|
||||||
|
|
||||||
export const onCreateCommentOnGroup = functions.firestore
|
|
||||||
.document('groups/{groupId}/comments/{commentId}')
|
|
||||||
.onCreate(async (change, context) => {
|
|
||||||
const { eventId } = context
|
|
||||||
const { groupId } = context.params as {
|
|
||||||
groupId: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const comment = change.data() as GroupComment
|
|
||||||
const creatorSnapshot = await firestore
|
|
||||||
.collection('users')
|
|
||||||
.doc(comment.userId)
|
|
||||||
.get()
|
|
||||||
if (!creatorSnapshot.exists) throw new Error('Could not find user')
|
|
||||||
|
|
||||||
const groupSnapshot = await firestore
|
|
||||||
.collection('groups')
|
|
||||||
.doc(groupId)
|
|
||||||
.get()
|
|
||||||
if (!groupSnapshot.exists) throw new Error('Could not find group')
|
|
||||||
|
|
||||||
const group = groupSnapshot.data() as Group
|
|
||||||
await firestore.collection('groups').doc(groupId).update({
|
|
||||||
mostRecentChatActivityTime: comment.createdTime,
|
|
||||||
})
|
|
||||||
|
|
||||||
await Promise.all(
|
|
||||||
group.memberIds.map(async (memberId) => {
|
|
||||||
return await createGroupCommentNotification(
|
|
||||||
creatorSnapshot.data() as User,
|
|
||||||
memberId,
|
|
||||||
comment,
|
|
||||||
group,
|
|
||||||
eventId
|
|
||||||
)
|
|
||||||
})
|
|
||||||
)
|
|
||||||
})
|
|
|
@ -1,28 +0,0 @@
|
||||||
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
|
|
||||||
await createNotification(
|
|
||||||
group.id,
|
|
||||||
'group',
|
|
||||||
'created',
|
|
||||||
groupCreator,
|
|
||||||
eventId,
|
|
||||||
group.about,
|
|
||||||
{
|
|
||||||
recipients: group.memberIds,
|
|
||||||
slug: group.slug,
|
|
||||||
title: group.name,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
})
|
|
|
@ -15,21 +15,68 @@ export const onUpdateGroup = functions.firestore
|
||||||
if (prevGroup.mostRecentActivityTime !== group.mostRecentActivityTime)
|
if (prevGroup.mostRecentActivityTime !== group.mostRecentActivityTime)
|
||||||
return
|
return
|
||||||
|
|
||||||
if (prevGroup.contractIds.length < group.contractIds.length) {
|
|
||||||
await firestore
|
|
||||||
.collection('groups')
|
|
||||||
.doc(group.id)
|
|
||||||
.update({ mostRecentContractAddedTime: Date.now() })
|
|
||||||
//TODO: create notification with isSeeOnHref set to the group's /group/slug/questions url
|
|
||||||
// but first, let the new /group/slug/chat notification permeate so that we can differentiate between the two
|
|
||||||
}
|
|
||||||
|
|
||||||
await firestore
|
await firestore
|
||||||
.collection('groups')
|
.collection('groups')
|
||||||
.doc(group.id)
|
.doc(group.id)
|
||||||
.update({ mostRecentActivityTime: Date.now() })
|
.update({ mostRecentActivityTime: Date.now() })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export const onCreateGroupContract = functions.firestore
|
||||||
|
.document('groups/{groupId}/groupContracts/{contractId}')
|
||||||
|
.onCreate(async (change) => {
|
||||||
|
const groupId = change.ref.parent.parent?.id
|
||||||
|
if (groupId)
|
||||||
|
await firestore
|
||||||
|
.collection('groups')
|
||||||
|
.doc(groupId)
|
||||||
|
.update({
|
||||||
|
mostRecentContractAddedTime: Date.now(),
|
||||||
|
totalContracts: admin.firestore.FieldValue.increment(1),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
export const onDeleteGroupContract = functions.firestore
|
||||||
|
.document('groups/{groupId}/groupContracts/{contractId}')
|
||||||
|
.onDelete(async (change) => {
|
||||||
|
const groupId = change.ref.parent.parent?.id
|
||||||
|
if (groupId)
|
||||||
|
await firestore
|
||||||
|
.collection('groups')
|
||||||
|
.doc(groupId)
|
||||||
|
.update({
|
||||||
|
mostRecentContractAddedTime: Date.now(),
|
||||||
|
totalContracts: admin.firestore.FieldValue.increment(-1),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
export const onCreateGroupMember = functions.firestore
|
||||||
|
.document('groups/{groupId}/groupMembers/{memberId}')
|
||||||
|
.onCreate(async (change) => {
|
||||||
|
const groupId = change.ref.parent.parent?.id
|
||||||
|
if (groupId)
|
||||||
|
await firestore
|
||||||
|
.collection('groups')
|
||||||
|
.doc(groupId)
|
||||||
|
.update({
|
||||||
|
mostRecentActivityTime: Date.now(),
|
||||||
|
totalMembers: admin.firestore.FieldValue.increment(1),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
export const onDeleteGroupMember = functions.firestore
|
||||||
|
.document('groups/{groupId}/groupMembers/{memberId}')
|
||||||
|
.onDelete(async (change) => {
|
||||||
|
const groupId = change.ref.parent.parent?.id
|
||||||
|
if (groupId)
|
||||||
|
await firestore
|
||||||
|
.collection('groups')
|
||||||
|
.doc(groupId)
|
||||||
|
.update({
|
||||||
|
mostRecentActivityTime: Date.now(),
|
||||||
|
totalMembers: admin.firestore.FieldValue.increment(-1),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
export async function removeGroupLinks(group: Group, contractIds: string[]) {
|
export async function removeGroupLinks(group: Group, contractIds: string[]) {
|
||||||
for (const contractId of contractIds) {
|
for (const contractId of contractIds) {
|
||||||
const contract = await getContract(contractId)
|
const contract = await getContract(contractId)
|
||||||
|
|
|
@ -1,108 +0,0 @@
|
||||||
import * as admin from 'firebase-admin'
|
|
||||||
|
|
||||||
import { initAdmin } from './script-init'
|
|
||||||
import { getValues, isProd } from '../utils'
|
|
||||||
import { CATEGORIES_GROUP_SLUG_POSTFIX } from 'common/categories'
|
|
||||||
import { Group, GroupLink } from 'common/group'
|
|
||||||
import { uniq } from 'lodash'
|
|
||||||
import { Contract } from 'common/contract'
|
|
||||||
import { User } from 'common/user'
|
|
||||||
import { filterDefined } from 'common/util/array'
|
|
||||||
import {
|
|
||||||
DEV_HOUSE_LIQUIDITY_PROVIDER_ID,
|
|
||||||
HOUSE_LIQUIDITY_PROVIDER_ID,
|
|
||||||
} from 'common/antes'
|
|
||||||
|
|
||||||
initAdmin()
|
|
||||||
|
|
||||||
const adminFirestore = admin.firestore()
|
|
||||||
|
|
||||||
const convertCategoriesToGroupsInternal = async (categories: string[]) => {
|
|
||||||
for (const category of categories) {
|
|
||||||
const markets = await getValues<Contract>(
|
|
||||||
adminFirestore
|
|
||||||
.collection('contracts')
|
|
||||||
.where('lowercaseTags', 'array-contains', category.toLowerCase())
|
|
||||||
)
|
|
||||||
const slug = category.toLowerCase() + CATEGORIES_GROUP_SLUG_POSTFIX
|
|
||||||
const oldGroup = await getValues<Group>(
|
|
||||||
adminFirestore.collection('groups').where('slug', '==', slug)
|
|
||||||
)
|
|
||||||
if (oldGroup.length > 0) {
|
|
||||||
console.log(`Found old group for ${category}`)
|
|
||||||
await adminFirestore.collection('groups').doc(oldGroup[0].id).delete()
|
|
||||||
}
|
|
||||||
|
|
||||||
const allUsers = await getValues<User>(adminFirestore.collection('users'))
|
|
||||||
const groupUsers = filterDefined(
|
|
||||||
allUsers.map((user: User) => {
|
|
||||||
if (!user.followedCategories || user.followedCategories.length === 0)
|
|
||||||
return user.id
|
|
||||||
if (!user.followedCategories.includes(category.toLowerCase()))
|
|
||||||
return null
|
|
||||||
return user.id
|
|
||||||
})
|
|
||||||
)
|
|
||||||
|
|
||||||
const manifoldAccount = isProd()
|
|
||||||
? HOUSE_LIQUIDITY_PROVIDER_ID
|
|
||||||
: DEV_HOUSE_LIQUIDITY_PROVIDER_ID
|
|
||||||
const newGroupRef = await adminFirestore.collection('groups').doc()
|
|
||||||
const newGroup: Group = {
|
|
||||||
id: newGroupRef.id,
|
|
||||||
name: category,
|
|
||||||
slug,
|
|
||||||
creatorId: manifoldAccount,
|
|
||||||
createdTime: Date.now(),
|
|
||||||
anyoneCanJoin: true,
|
|
||||||
memberIds: [manifoldAccount],
|
|
||||||
about: 'Default group for all things related to ' + category,
|
|
||||||
mostRecentActivityTime: Date.now(),
|
|
||||||
contractIds: markets.map((market) => market.id),
|
|
||||||
chatDisabled: true,
|
|
||||||
}
|
|
||||||
|
|
||||||
await adminFirestore.collection('groups').doc(newGroupRef.id).set(newGroup)
|
|
||||||
// Update group with new memberIds to avoid notifying everyone
|
|
||||||
await adminFirestore
|
|
||||||
.collection('groups')
|
|
||||||
.doc(newGroupRef.id)
|
|
||||||
.update({
|
|
||||||
memberIds: uniq(groupUsers),
|
|
||||||
})
|
|
||||||
|
|
||||||
for (const market of markets) {
|
|
||||||
if (market.groupLinks?.map((l) => l.groupId).includes(newGroup.id))
|
|
||||||
continue // already in that group
|
|
||||||
|
|
||||||
const newGroupLinks = [
|
|
||||||
...(market.groupLinks ?? []),
|
|
||||||
{
|
|
||||||
groupId: newGroup.id,
|
|
||||||
createdTime: Date.now(),
|
|
||||||
slug: newGroup.slug,
|
|
||||||
name: newGroup.name,
|
|
||||||
} as GroupLink,
|
|
||||||
]
|
|
||||||
await adminFirestore
|
|
||||||
.collection('contracts')
|
|
||||||
.doc(market.id)
|
|
||||||
.update({
|
|
||||||
groupSlugs: uniq([...(market.groupSlugs ?? []), newGroup.slug]),
|
|
||||||
groupLinks: newGroupLinks,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function convertCategoriesToGroups() {
|
|
||||||
// const defaultCategories = Object.values(DEFAULT_CATEGORIES)
|
|
||||||
const moreCategories = ['world', 'culture']
|
|
||||||
await convertCategoriesToGroupsInternal(moreCategories)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (require.main === module) {
|
|
||||||
convertCategoriesToGroups()
|
|
||||||
.then(() => process.exit())
|
|
||||||
.catch(console.log)
|
|
||||||
}
|
|
|
@ -4,21 +4,23 @@ import * as admin from 'firebase-admin'
|
||||||
import { initAdmin } from './script-init'
|
import { initAdmin } from './script-init'
|
||||||
import { isProd, log } from '../utils'
|
import { isProd, log } from '../utils'
|
||||||
import { getSlug } from '../create-group'
|
import { getSlug } from '../create-group'
|
||||||
import { Group } from '../../../common/group'
|
import { Group, GroupLink } from '../../../common/group'
|
||||||
|
import { uniq } from 'lodash'
|
||||||
|
import { Contract } from 'common/contract'
|
||||||
|
|
||||||
const getTaggedContractIds = async (tag: string) => {
|
const getTaggedContracts = async (tag: string) => {
|
||||||
const firestore = admin.firestore()
|
const firestore = admin.firestore()
|
||||||
const results = await firestore
|
const results = await firestore
|
||||||
.collection('contracts')
|
.collection('contracts')
|
||||||
.where('lowercaseTags', 'array-contains', tag.toLowerCase())
|
.where('lowercaseTags', 'array-contains', tag.toLowerCase())
|
||||||
.get()
|
.get()
|
||||||
return results.docs.map((d) => d.id)
|
return results.docs.map((d) => d.data() as Contract)
|
||||||
}
|
}
|
||||||
|
|
||||||
const createGroup = async (
|
const createGroup = async (
|
||||||
name: string,
|
name: string,
|
||||||
about: string,
|
about: string,
|
||||||
contractIds: string[]
|
contracts: Contract[]
|
||||||
) => {
|
) => {
|
||||||
const firestore = admin.firestore()
|
const firestore = admin.firestore()
|
||||||
const creatorId = isProd()
|
const creatorId = isProd()
|
||||||
|
@ -36,21 +38,60 @@ const createGroup = async (
|
||||||
about,
|
about,
|
||||||
createdTime: now,
|
createdTime: now,
|
||||||
mostRecentActivityTime: now,
|
mostRecentActivityTime: now,
|
||||||
contractIds: contractIds,
|
|
||||||
anyoneCanJoin: true,
|
anyoneCanJoin: true,
|
||||||
memberIds: [],
|
totalContracts: contracts.length,
|
||||||
|
totalMembers: 1,
|
||||||
}
|
}
|
||||||
return await groupRef.create(group)
|
await groupRef.create(group)
|
||||||
|
// create a GroupMemberDoc for the creator
|
||||||
|
const memberDoc = groupRef.collection('groupMembers').doc(creatorId)
|
||||||
|
await memberDoc.create({
|
||||||
|
userId: creatorId,
|
||||||
|
createdTime: now,
|
||||||
|
})
|
||||||
|
|
||||||
|
// create GroupContractDocs for each contractId
|
||||||
|
await Promise.all(
|
||||||
|
contracts
|
||||||
|
.map((c) => c.id)
|
||||||
|
.map((contractId) =>
|
||||||
|
groupRef.collection('groupContracts').doc(contractId).create({
|
||||||
|
contractId,
|
||||||
|
createdTime: now,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
)
|
||||||
|
for (const market of contracts) {
|
||||||
|
if (market.groupLinks?.map((l) => l.groupId).includes(group.id)) continue // already in that group
|
||||||
|
|
||||||
|
const newGroupLinks = [
|
||||||
|
...(market.groupLinks ?? []),
|
||||||
|
{
|
||||||
|
groupId: group.id,
|
||||||
|
createdTime: Date.now(),
|
||||||
|
slug: group.slug,
|
||||||
|
name: group.name,
|
||||||
|
} as GroupLink,
|
||||||
|
]
|
||||||
|
await firestore
|
||||||
|
.collection('contracts')
|
||||||
|
.doc(market.id)
|
||||||
|
.update({
|
||||||
|
groupSlugs: uniq([...(market.groupSlugs ?? []), group.slug]),
|
||||||
|
groupLinks: newGroupLinks,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return { status: 'success', group: group }
|
||||||
}
|
}
|
||||||
|
|
||||||
const convertTagToGroup = async (tag: string, groupName: string) => {
|
const convertTagToGroup = async (tag: string, groupName: string) => {
|
||||||
log(`Looking up contract IDs with tag ${tag}...`)
|
log(`Looking up contract IDs with tag ${tag}...`)
|
||||||
const contractIds = await getTaggedContractIds(tag)
|
const contracts = await getTaggedContracts(tag)
|
||||||
log(`${contractIds.length} contracts found.`)
|
log(`${contracts.length} contracts found.`)
|
||||||
if (contractIds.length > 0) {
|
if (contracts.length > 0) {
|
||||||
log(`Creating group ${groupName}...`)
|
log(`Creating group ${groupName}...`)
|
||||||
const about = `Contracts that used to be tagged ${tag}.`
|
const about = `Contracts that used to be tagged ${tag}.`
|
||||||
const result = await createGroup(groupName, about, contractIds)
|
const result = await createGroup(groupName, about, contracts)
|
||||||
log(`Done. Group: `, result)
|
log(`Done. Group: `, result)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
109
functions/src/scripts/update-groups.ts
Normal file
109
functions/src/scripts/update-groups.ts
Normal file
|
@ -0,0 +1,109 @@
|
||||||
|
import * as admin from 'firebase-admin'
|
||||||
|
import { Group } from 'common/group'
|
||||||
|
import { initAdmin } from 'functions/src/scripts/script-init'
|
||||||
|
import { log } from '../utils'
|
||||||
|
|
||||||
|
const getGroups = async () => {
|
||||||
|
const firestore = admin.firestore()
|
||||||
|
const groups = await firestore.collection('groups').get()
|
||||||
|
return groups.docs.map((doc) => doc.data() as Group)
|
||||||
|
}
|
||||||
|
|
||||||
|
const createContractIdForGroup = async (
|
||||||
|
groupId: string,
|
||||||
|
contractId: string
|
||||||
|
) => {
|
||||||
|
const firestore = admin.firestore()
|
||||||
|
const now = Date.now()
|
||||||
|
const contractDoc = await firestore
|
||||||
|
.collection('groups')
|
||||||
|
.doc(groupId)
|
||||||
|
.collection('groupContracts')
|
||||||
|
.doc(contractId)
|
||||||
|
.get()
|
||||||
|
if (!contractDoc.exists)
|
||||||
|
await firestore
|
||||||
|
.collection('groups')
|
||||||
|
.doc(groupId)
|
||||||
|
.collection('groupContracts')
|
||||||
|
.doc(contractId)
|
||||||
|
.create({
|
||||||
|
contractId,
|
||||||
|
createdTime: now,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const createMemberForGroup = async (groupId: string, userId: string) => {
|
||||||
|
const firestore = admin.firestore()
|
||||||
|
const now = Date.now()
|
||||||
|
const memberDoc = await firestore
|
||||||
|
.collection('groups')
|
||||||
|
.doc(groupId)
|
||||||
|
.collection('groupMembers')
|
||||||
|
.doc(userId)
|
||||||
|
.get()
|
||||||
|
if (!memberDoc.exists)
|
||||||
|
await firestore
|
||||||
|
.collection('groups')
|
||||||
|
.doc(groupId)
|
||||||
|
.collection('groupMembers')
|
||||||
|
.doc(userId)
|
||||||
|
.create({
|
||||||
|
userId,
|
||||||
|
createdTime: now,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
async function convertGroupFieldsToGroupDocuments() {
|
||||||
|
const groups = await getGroups()
|
||||||
|
for (const group of groups) {
|
||||||
|
log('updating group', group.slug)
|
||||||
|
const groupRef = admin.firestore().collection('groups').doc(group.id)
|
||||||
|
const totalMembers = (await groupRef.collection('groupMembers').get()).size
|
||||||
|
const totalContracts = (await groupRef.collection('groupContracts').get())
|
||||||
|
.size
|
||||||
|
if (
|
||||||
|
totalMembers === group.memberIds?.length &&
|
||||||
|
totalContracts === group.contractIds?.length
|
||||||
|
) {
|
||||||
|
log('group already converted', group.slug)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const contractStart = totalContracts - 1 < 0 ? 0 : totalContracts - 1
|
||||||
|
const membersStart = totalMembers - 1 < 0 ? 0 : totalMembers - 1
|
||||||
|
for (const contractId of group.contractIds?.slice(
|
||||||
|
contractStart,
|
||||||
|
group.contractIds?.length
|
||||||
|
) ?? []) {
|
||||||
|
await createContractIdForGroup(group.id, contractId)
|
||||||
|
}
|
||||||
|
for (const userId of group.memberIds?.slice(
|
||||||
|
membersStart,
|
||||||
|
group.memberIds?.length
|
||||||
|
) ?? []) {
|
||||||
|
await createMemberForGroup(group.id, userId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateTotalContractsAndMembers() {
|
||||||
|
const groups = await getGroups()
|
||||||
|
for (const group of groups) {
|
||||||
|
log('updating group total contracts and members', group.slug)
|
||||||
|
const groupRef = admin.firestore().collection('groups').doc(group.id)
|
||||||
|
const totalMembers = (await groupRef.collection('groupMembers').get()).size
|
||||||
|
const totalContracts = (await groupRef.collection('groupContracts').get())
|
||||||
|
.size
|
||||||
|
await groupRef.update({
|
||||||
|
totalMembers,
|
||||||
|
totalContracts,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (require.main === module) {
|
||||||
|
initAdmin()
|
||||||
|
// convertGroupFieldsToGroupDocuments()
|
||||||
|
updateTotalContractsAndMembers()
|
||||||
|
}
|
|
@ -282,8 +282,8 @@ function ContractSearchControls(props: {
|
||||||
: DEFAULT_CATEGORY_GROUPS.map((g) => g.slug)
|
: DEFAULT_CATEGORY_GROUPS.map((g) => g.slug)
|
||||||
|
|
||||||
const memberPillGroups = sortBy(
|
const memberPillGroups = sortBy(
|
||||||
memberGroups.filter((group) => group.contractIds.length > 0),
|
memberGroups.filter((group) => group.totalContracts > 0),
|
||||||
(group) => group.contractIds.length
|
(group) => group.totalContracts
|
||||||
).reverse()
|
).reverse()
|
||||||
|
|
||||||
const pillGroups: { name: string; slug: string }[] =
|
const pillGroups: { name: string; slug: string }[] =
|
||||||
|
|
|
@ -7,13 +7,13 @@ import { Button } from 'web/components/button'
|
||||||
import { GroupSelector } from 'web/components/groups/group-selector'
|
import { GroupSelector } from 'web/components/groups/group-selector'
|
||||||
import {
|
import {
|
||||||
addContractToGroup,
|
addContractToGroup,
|
||||||
canModifyGroupContracts,
|
|
||||||
removeContractFromGroup,
|
removeContractFromGroup,
|
||||||
} from 'web/lib/firebase/groups'
|
} from 'web/lib/firebase/groups'
|
||||||
import { User } from 'common/user'
|
import { User } from 'common/user'
|
||||||
import { Contract } from 'common/contract'
|
import { Contract } from 'common/contract'
|
||||||
import { SiteLink } from 'web/components/site-link'
|
import { SiteLink } from 'web/components/site-link'
|
||||||
import { useGroupsWithContract } from 'web/hooks/use-group'
|
import { useGroupsWithContract, useMemberGroupIds } from 'web/hooks/use-group'
|
||||||
|
import { Group } from 'common/group'
|
||||||
|
|
||||||
export function ContractGroupsList(props: {
|
export function ContractGroupsList(props: {
|
||||||
contract: Contract
|
contract: Contract
|
||||||
|
@ -22,6 +22,15 @@ export function ContractGroupsList(props: {
|
||||||
const { user, contract } = props
|
const { user, contract } = props
|
||||||
const { groupLinks } = contract
|
const { groupLinks } = contract
|
||||||
const groups = useGroupsWithContract(contract)
|
const groups = useGroupsWithContract(contract)
|
||||||
|
const memberGroupIds = useMemberGroupIds(user)
|
||||||
|
|
||||||
|
const canModifyGroupContracts = (group: Group, userId: string) => {
|
||||||
|
return (
|
||||||
|
group.creatorId === userId ||
|
||||||
|
group.anyoneCanJoin ||
|
||||||
|
memberGroupIds?.includes(group.id)
|
||||||
|
)
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<Col className={'gap-2'}>
|
<Col className={'gap-2'}>
|
||||||
<span className={'text-xl text-indigo-700'}>
|
<span className={'text-xl text-indigo-700'}>
|
||||||
|
@ -61,7 +70,7 @@ export function ContractGroupsList(props: {
|
||||||
<Button
|
<Button
|
||||||
color={'gray-white'}
|
color={'gray-white'}
|
||||||
size={'xs'}
|
size={'xs'}
|
||||||
onClick={() => removeContractFromGroup(group, contract, user.id)}
|
onClick={() => removeContractFromGroup(group, contract)}
|
||||||
>
|
>
|
||||||
<XIcon className="h-4 w-4 text-gray-500" />
|
<XIcon className="h-4 w-4 text-gray-500" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
|
@ -3,17 +3,16 @@ import clsx from 'clsx'
|
||||||
import { PencilIcon } from '@heroicons/react/outline'
|
import { PencilIcon } from '@heroicons/react/outline'
|
||||||
|
|
||||||
import { Group } from 'common/group'
|
import { Group } from 'common/group'
|
||||||
import { deleteGroup, updateGroup } from 'web/lib/firebase/groups'
|
import { deleteGroup, joinGroup } from 'web/lib/firebase/groups'
|
||||||
import { Spacer } from '../layout/spacer'
|
import { Spacer } from '../layout/spacer'
|
||||||
import { useRouter } from 'next/router'
|
import { useRouter } from 'next/router'
|
||||||
import { Modal } from 'web/components/layout/modal'
|
import { Modal } from 'web/components/layout/modal'
|
||||||
import { FilterSelectUsers } from 'web/components/filter-select-users'
|
import { FilterSelectUsers } from 'web/components/filter-select-users'
|
||||||
import { User } from 'common/user'
|
import { User } from 'common/user'
|
||||||
import { uniq } from 'lodash'
|
import { useMemberIds } from 'web/hooks/use-group'
|
||||||
|
|
||||||
export function EditGroupButton(props: { group: Group; className?: string }) {
|
export function EditGroupButton(props: { group: Group; className?: string }) {
|
||||||
const { group, className } = props
|
const { group, className } = props
|
||||||
const { memberIds } = group
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
const [name, setName] = useState(group.name)
|
const [name, setName] = useState(group.name)
|
||||||
|
@ -21,7 +20,7 @@ export function EditGroupButton(props: { group: Group; className?: string }) {
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||||
const [addMemberUsers, setAddMemberUsers] = useState<User[]>([])
|
const [addMemberUsers, setAddMemberUsers] = useState<User[]>([])
|
||||||
|
const memberIds = useMemberIds(group.id)
|
||||||
function updateOpen(newOpen: boolean) {
|
function updateOpen(newOpen: boolean) {
|
||||||
setAddMemberUsers([])
|
setAddMemberUsers([])
|
||||||
setOpen(newOpen)
|
setOpen(newOpen)
|
||||||
|
@ -33,11 +32,7 @@ export function EditGroupButton(props: { group: Group; className?: string }) {
|
||||||
const onSubmit = async () => {
|
const onSubmit = async () => {
|
||||||
setIsSubmitting(true)
|
setIsSubmitting(true)
|
||||||
|
|
||||||
await updateGroup(group, {
|
await Promise.all(addMemberUsers.map((user) => joinGroup(group, user.id)))
|
||||||
name,
|
|
||||||
about,
|
|
||||||
memberIds: uniq([...memberIds, ...addMemberUsers.map((user) => user.id)]),
|
|
||||||
})
|
|
||||||
|
|
||||||
setIsSubmitting(false)
|
setIsSubmitting(false)
|
||||||
updateOpen(false)
|
updateOpen(false)
|
||||||
|
|
|
@ -1,391 +0,0 @@
|
||||||
import { Row } from 'web/components/layout/row'
|
|
||||||
import { Col } from 'web/components/layout/col'
|
|
||||||
import { PrivateUser, User } from 'common/user'
|
|
||||||
import React, { useEffect, memo, useState, useMemo } from 'react'
|
|
||||||
import { Avatar } from 'web/components/avatar'
|
|
||||||
import { Group } from 'common/group'
|
|
||||||
import { Comment, GroupComment } from 'common/comment'
|
|
||||||
import { createCommentOnGroup } from 'web/lib/firebase/comments'
|
|
||||||
import { CommentInputTextArea } 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 { CopyLinkDateTimeComponent } from 'web/components/feed/copy-link-date-time'
|
|
||||||
import { CommentTipMap, CommentTips } from 'web/hooks/use-tip-txns'
|
|
||||||
import { Tipper } from 'web/components/tipper'
|
|
||||||
import { sum } from 'lodash'
|
|
||||||
import { formatMoney } from 'common/util/format'
|
|
||||||
import { useWindowSize } from 'web/hooks/use-window-size'
|
|
||||||
import { Content, useTextEditor } from 'web/components/editor'
|
|
||||||
import { useUnseenNotifications } from 'web/hooks/use-notifications'
|
|
||||||
import { ChevronDownIcon, UsersIcon } from '@heroicons/react/outline'
|
|
||||||
import { setNotificationsAsSeen } from 'web/pages/notifications'
|
|
||||||
import { usePrivateUser } from 'web/hooks/use-user'
|
|
||||||
import { UserLink } from 'web/components/user-link'
|
|
||||||
|
|
||||||
export function GroupChat(props: {
|
|
||||||
messages: GroupComment[]
|
|
||||||
user: User | null | undefined
|
|
||||||
group: Group
|
|
||||||
tips: CommentTipMap
|
|
||||||
}) {
|
|
||||||
const { messages, user, group, tips } = props
|
|
||||||
|
|
||||||
const privateUser = usePrivateUser()
|
|
||||||
|
|
||||||
const { editor, upload } = useTextEditor({
|
|
||||||
simple: true,
|
|
||||||
placeholder: 'Send a message',
|
|
||||||
})
|
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
|
||||||
const [scrollToBottomRef, setScrollToBottomRef] =
|
|
||||||
useState<HTMLDivElement | null>(null)
|
|
||||||
const [scrollToMessageId, setScrollToMessageId] = useState('')
|
|
||||||
const [scrollToMessageRef, setScrollToMessageRef] =
|
|
||||||
useState<HTMLDivElement | null>(null)
|
|
||||||
const [replyToUser, setReplyToUser] = useState<any>()
|
|
||||||
|
|
||||||
const router = useRouter()
|
|
||||||
const isMember = user && group.memberIds.includes(user?.id)
|
|
||||||
|
|
||||||
const { width, height } = useWindowSize()
|
|
||||||
const [containerRef, setContainerRef] = useState<HTMLDivElement | null>(null)
|
|
||||||
// Subtract bottom bar when it's showing (less than lg screen)
|
|
||||||
const bottomBarHeight = (width ?? 0) < 1024 ? 58 : 0
|
|
||||||
const remainingHeight =
|
|
||||||
(height ?? 0) - (containerRef?.offsetTop ?? 0) - bottomBarHeight
|
|
||||||
|
|
||||||
// array of groups, where each group is an array of messages that are displayed as one
|
|
||||||
const groupedMessages = useMemo(() => {
|
|
||||||
// Group messages with createdTime within 2 minutes of each other.
|
|
||||||
const tempGrouped: GroupComment[][] = []
|
|
||||||
for (let i = 0; i < messages.length; i++) {
|
|
||||||
const message = messages[i]
|
|
||||||
if (i === 0) tempGrouped.push([message])
|
|
||||||
else {
|
|
||||||
const prevMessage = messages[i - 1]
|
|
||||||
const diff = message.createdTime - prevMessage.createdTime
|
|
||||||
const creatorsMatch = message.userId === prevMessage.userId
|
|
||||||
if (diff < 2 * 60 * 1000 && creatorsMatch) {
|
|
||||||
tempGrouped.at(-1)?.push(message)
|
|
||||||
} else {
|
|
||||||
tempGrouped.push([message])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return tempGrouped
|
|
||||||
}, [messages])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
scrollToMessageRef?.scrollIntoView()
|
|
||||||
}, [scrollToMessageRef])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (scrollToBottomRef)
|
|
||||||
scrollToBottomRef.scrollTo({ top: scrollToBottomRef.scrollHeight || 0 })
|
|
||||||
// Must also listen to groupedMessages as they update the height of the messaging window
|
|
||||||
}, [scrollToBottomRef, groupedMessages])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const elementInUrl = router.asPath.split('#')[1]
|
|
||||||
if (messages.map((m) => m.id).includes(elementInUrl)) {
|
|
||||||
setScrollToMessageId(elementInUrl)
|
|
||||||
}
|
|
||||||
}, [messages, router.asPath])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// is mobile?
|
|
||||||
if (width && width > 720) focusInput()
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [width])
|
|
||||||
|
|
||||||
function onReplyClick(comment: Comment) {
|
|
||||||
setReplyToUser({ id: comment.userId, username: comment.userUsername })
|
|
||||||
}
|
|
||||||
|
|
||||||
async function submitMessage() {
|
|
||||||
if (!user) {
|
|
||||||
track('sign in to comment')
|
|
||||||
return await firebaseLogin()
|
|
||||||
}
|
|
||||||
if (!editor || editor.isEmpty || isSubmitting) return
|
|
||||||
setIsSubmitting(true)
|
|
||||||
await createCommentOnGroup(group.id, editor.getJSON(), user)
|
|
||||||
editor.commands.clearContent()
|
|
||||||
setIsSubmitting(false)
|
|
||||||
setReplyToUser(undefined)
|
|
||||||
focusInput()
|
|
||||||
}
|
|
||||||
function focusInput() {
|
|
||||||
editor?.commands.focus()
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Col ref={setContainerRef} style={{ height: remainingHeight }}>
|
|
||||||
<Col
|
|
||||||
className={
|
|
||||||
'w-full flex-1 space-y-2 overflow-x-hidden overflow-y-scroll pt-2'
|
|
||||||
}
|
|
||||||
ref={setScrollToBottomRef}
|
|
||||||
>
|
|
||||||
{groupedMessages.map((messages) => (
|
|
||||||
<GroupMessage
|
|
||||||
user={user}
|
|
||||||
key={`group ${messages[0].id}`}
|
|
||||||
comments={messages}
|
|
||||||
group={group}
|
|
||||||
onReplyClick={onReplyClick}
|
|
||||||
highlight={messages[0].id === scrollToMessageId}
|
|
||||||
setRef={
|
|
||||||
scrollToMessageId === messages[0].id
|
|
||||||
? setScrollToMessageRef
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
tips={tips[messages[0].id] ?? {}}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
{messages.length === 0 && (
|
|
||||||
<div className="p-2 text-gray-500">
|
|
||||||
No messages yet. Why not{isMember ? ` ` : ' join and '}
|
|
||||||
<button
|
|
||||||
className={'cursor-pointer font-bold text-gray-700'}
|
|
||||||
onClick={focusInput}
|
|
||||||
>
|
|
||||||
add one?
|
|
||||||
</button>
|
|
||||||
</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
|
|
||||||
editor={editor}
|
|
||||||
upload={upload}
|
|
||||||
user={user}
|
|
||||||
replyToUser={replyToUser}
|
|
||||||
submitComment={submitMessage}
|
|
||||||
isSubmitting={isSubmitting}
|
|
||||||
submitOnEnter
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{privateUser && (
|
|
||||||
<GroupChatNotificationsIcon
|
|
||||||
group={group}
|
|
||||||
privateUser={privateUser}
|
|
||||||
shouldSetAsSeen={true}
|
|
||||||
hidden={true}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Col>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function GroupChatInBubble(props: {
|
|
||||||
messages: GroupComment[]
|
|
||||||
user: User | null | undefined
|
|
||||||
privateUser: PrivateUser | null | undefined
|
|
||||||
group: Group
|
|
||||||
tips: CommentTipMap
|
|
||||||
}) {
|
|
||||||
const { messages, user, group, tips, privateUser } = props
|
|
||||||
const [shouldShowChat, setShouldShowChat] = useState(false)
|
|
||||||
const router = useRouter()
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const groupsWithChatEmphasis = [
|
|
||||||
'welcome',
|
|
||||||
'bugs',
|
|
||||||
'manifold-features-25bad7c7792e',
|
|
||||||
'updates',
|
|
||||||
]
|
|
||||||
if (
|
|
||||||
router.asPath.includes('/chat') ||
|
|
||||||
groupsWithChatEmphasis.includes(
|
|
||||||
router.asPath.split('/group/')[1].split('/')[0]
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
setShouldShowChat(true)
|
|
||||||
}
|
|
||||||
// Leave chat open between groups if user is using chat?
|
|
||||||
else {
|
|
||||||
setShouldShowChat(false)
|
|
||||||
}
|
|
||||||
}, [router.asPath])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Col
|
|
||||||
className={clsx(
|
|
||||||
'fixed right-0 bottom-[0px] h-1 w-full sm:bottom-[20px] sm:right-20 sm:w-2/3 md:w-1/2 lg:right-24 lg:w-1/3 xl:right-32 xl:w-1/4',
|
|
||||||
shouldShowChat ? 'p-2m z-10 h-screen bg-white' : ''
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{shouldShowChat && (
|
|
||||||
<GroupChat messages={messages} user={user} group={group} tips={tips} />
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={clsx(
|
|
||||||
'fixed right-1 inline-flex items-center rounded-full border md:right-2 lg:right-5 xl:right-10' +
|
|
||||||
' border-transparent p-3 text-white shadow-sm lg:p-4' +
|
|
||||||
' focus:outline-none focus:ring-2 focus:ring-offset-2 ' +
|
|
||||||
' bottom-[70px] ',
|
|
||||||
shouldShowChat
|
|
||||||
? 'bottom-auto top-2 bg-gray-600 hover:bg-gray-400 focus:ring-gray-500 sm:bottom-[70px] sm:top-auto '
|
|
||||||
: ' bg-indigo-600 hover:bg-indigo-700 focus:ring-indigo-500'
|
|
||||||
)}
|
|
||||||
onClick={() => {
|
|
||||||
// router.push('/chat')
|
|
||||||
setShouldShowChat(!shouldShowChat)
|
|
||||||
track('mobile group chat button')
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{!shouldShowChat ? (
|
|
||||||
<UsersIcon className="h-10 w-10" aria-hidden="true" />
|
|
||||||
) : (
|
|
||||||
<ChevronDownIcon className={'h-10 w-10'} aria-hidden={'true'} />
|
|
||||||
)}
|
|
||||||
{privateUser && (
|
|
||||||
<GroupChatNotificationsIcon
|
|
||||||
group={group}
|
|
||||||
privateUser={privateUser}
|
|
||||||
shouldSetAsSeen={shouldShowChat}
|
|
||||||
hidden={false}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</Col>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function GroupChatNotificationsIcon(props: {
|
|
||||||
group: Group
|
|
||||||
privateUser: PrivateUser
|
|
||||||
shouldSetAsSeen: boolean
|
|
||||||
hidden: boolean
|
|
||||||
}) {
|
|
||||||
const { privateUser, group, shouldSetAsSeen, hidden } = props
|
|
||||||
const notificationsForThisGroup = useUnseenNotifications(
|
|
||||||
privateUser
|
|
||||||
// Disabled tracking by customHref for now.
|
|
||||||
// {
|
|
||||||
// customHref: `/group/${group.slug}`,
|
|
||||||
// }
|
|
||||||
)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!notificationsForThisGroup) return
|
|
||||||
|
|
||||||
notificationsForThisGroup.forEach((notification) => {
|
|
||||||
if (
|
|
||||||
(shouldSetAsSeen && notification.isSeenOnHref?.includes('chat')) ||
|
|
||||||
// old style chat notif that simply ended with the group slug
|
|
||||||
notification.isSeenOnHref?.endsWith(group.slug)
|
|
||||||
) {
|
|
||||||
setNotificationsAsSeen([notification])
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}, [group.slug, notificationsForThisGroup, shouldSetAsSeen])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={
|
|
||||||
!hidden &&
|
|
||||||
notificationsForThisGroup &&
|
|
||||||
notificationsForThisGroup.length > 0 &&
|
|
||||||
!shouldSetAsSeen
|
|
||||||
? 'absolute right-4 top-4 h-3 w-3 rounded-full border-2 border-white bg-red-500'
|
|
||||||
: 'hidden'
|
|
||||||
}
|
|
||||||
></div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const GroupMessage = memo(function GroupMessage_(props: {
|
|
||||||
user: User | null | undefined
|
|
||||||
comments: GroupComment[]
|
|
||||||
group: Group
|
|
||||||
onReplyClick?: (comment: Comment) => void
|
|
||||||
setRef?: (ref: HTMLDivElement) => void
|
|
||||||
highlight?: boolean
|
|
||||||
tips: CommentTips
|
|
||||||
}) {
|
|
||||||
const { comments, onReplyClick, group, setRef, highlight, user, tips } = props
|
|
||||||
const first = comments[0]
|
|
||||||
const { id, userUsername, userName, userAvatarUrl, createdTime } = first
|
|
||||||
|
|
||||||
const isCreatorsComment = user && first.userId === user.id
|
|
||||||
return (
|
|
||||||
<Col
|
|
||||||
ref={setRef}
|
|
||||||
className={clsx(
|
|
||||||
isCreatorsComment ? 'mr-2 self-end' : '',
|
|
||||||
'w-fit max-w-sm gap-1 space-x-3 rounded-md bg-white p-1 text-sm text-gray-500 transition-colors duration-1000 sm:max-w-md sm:p-3 sm:leading-[1.3rem]',
|
|
||||||
highlight ? `-m-1 bg-indigo-500/[0.2] p-2` : ''
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Row className={'items-center'}>
|
|
||||||
{!isCreatorsComment && (
|
|
||||||
<Col>
|
|
||||||
<Avatar
|
|
||||||
className={'mx-2 ml-2.5'}
|
|
||||||
size={'xs'}
|
|
||||||
username={userUsername}
|
|
||||||
avatarUrl={userAvatarUrl}
|
|
||||||
/>
|
|
||||||
</Col>
|
|
||||||
)}
|
|
||||||
{!isCreatorsComment ? (
|
|
||||||
<UserLink username={userUsername} name={userName} />
|
|
||||||
) : (
|
|
||||||
<span className={'ml-2.5'}>{'You'}</span>
|
|
||||||
)}
|
|
||||||
<CopyLinkDateTimeComponent
|
|
||||||
prefix={'group'}
|
|
||||||
slug={group.slug}
|
|
||||||
createdTime={createdTime}
|
|
||||||
elementId={id}
|
|
||||||
/>
|
|
||||||
</Row>
|
|
||||||
<div className="mt-2 text-base text-black">
|
|
||||||
{comments.map((comment) => (
|
|
||||||
<Content
|
|
||||||
key={comment.id}
|
|
||||||
content={comment.content || comment.text}
|
|
||||||
smallImage
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<Row>
|
|
||||||
{!isCreatorsComment && onReplyClick && (
|
|
||||||
<button
|
|
||||||
className={
|
|
||||||
'self-start py-1 text-xs font-bold text-gray-500 hover:underline'
|
|
||||||
}
|
|
||||||
onClick={() => onReplyClick(first)}
|
|
||||||
>
|
|
||||||
Reply
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{isCreatorsComment && sum(Object.values(tips)) > 0 && (
|
|
||||||
<span className={'text-primary'}>
|
|
||||||
{formatMoney(sum(Object.values(tips)))}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{!isCreatorsComment && <Tipper comment={first} tips={tips} />}
|
|
||||||
</Row>
|
|
||||||
</Col>
|
|
||||||
)
|
|
||||||
})
|
|
|
@ -1,10 +1,10 @@
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
import { User } from 'common/user'
|
import { User } from 'common/user'
|
||||||
import { useEffect, useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useUser } from 'web/hooks/use-user'
|
import { useUser } from 'web/hooks/use-user'
|
||||||
import { withTracking } from 'web/lib/service/analytics'
|
import { withTracking } from 'web/lib/service/analytics'
|
||||||
import { Row } from 'web/components/layout/row'
|
import { Row } from 'web/components/layout/row'
|
||||||
import { useMemberGroups } from 'web/hooks/use-group'
|
import { useMemberGroups, useMemberIds } from 'web/hooks/use-group'
|
||||||
import { TextButton } from 'web/components/text-button'
|
import { TextButton } from 'web/components/text-button'
|
||||||
import { Group } from 'common/group'
|
import { Group } from 'common/group'
|
||||||
import { Modal } from 'web/components/layout/modal'
|
import { Modal } from 'web/components/layout/modal'
|
||||||
|
@ -17,9 +17,7 @@ import toast from 'react-hot-toast'
|
||||||
export function GroupsButton(props: { user: User }) {
|
export function GroupsButton(props: { user: User }) {
|
||||||
const { user } = props
|
const { user } = props
|
||||||
const [isOpen, setIsOpen] = useState(false)
|
const [isOpen, setIsOpen] = useState(false)
|
||||||
const groups = useMemberGroups(user.id, undefined, {
|
const groups = useMemberGroups(user.id)
|
||||||
by: 'mostRecentChatActivityTime',
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
@ -91,34 +89,12 @@ export function JoinOrLeaveGroupButton(props: {
|
||||||
}) {
|
}) {
|
||||||
const { group, small, className } = props
|
const { group, small, className } = props
|
||||||
const currentUser = useUser()
|
const currentUser = useUser()
|
||||||
const [isMember, setIsMember] = useState<boolean>(false)
|
const memberIds = useMemberIds(group.id)
|
||||||
useEffect(() => {
|
const isMember = memberIds?.includes(currentUser?.id ?? '') ?? false
|
||||||
if (currentUser && group.memberIds.includes(currentUser.id)) {
|
|
||||||
setIsMember(group.memberIds.includes(currentUser.id))
|
|
||||||
}
|
|
||||||
}, [currentUser, group])
|
|
||||||
|
|
||||||
const onJoinGroup = () => {
|
|
||||||
if (!currentUser) return
|
|
||||||
setIsMember(true)
|
|
||||||
joinGroup(group, currentUser.id).catch(() => {
|
|
||||||
setIsMember(false)
|
|
||||||
toast.error('Failed to join group')
|
|
||||||
})
|
|
||||||
}
|
|
||||||
const onLeaveGroup = () => {
|
|
||||||
if (!currentUser) return
|
|
||||||
setIsMember(false)
|
|
||||||
leaveGroup(group, currentUser.id).catch(() => {
|
|
||||||
setIsMember(true)
|
|
||||||
toast.error('Failed to leave group')
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const smallStyle =
|
const smallStyle =
|
||||||
'btn !btn-xs border-2 border-gray-500 bg-white normal-case text-gray-500 hover:border-gray-500 hover:bg-white hover:text-gray-500'
|
'btn !btn-xs border-2 border-gray-500 bg-white normal-case text-gray-500 hover:border-gray-500 hover:bg-white hover:text-gray-500'
|
||||||
|
|
||||||
if (!currentUser || isMember === undefined) {
|
if (!currentUser) {
|
||||||
if (!group.anyoneCanJoin)
|
if (!group.anyoneCanJoin)
|
||||||
return <div className={clsx(className, 'text-gray-500')}>Closed</div>
|
return <div className={clsx(className, 'text-gray-500')}>Closed</div>
|
||||||
return (
|
return (
|
||||||
|
@ -130,6 +106,16 @@ export function JoinOrLeaveGroupButton(props: {
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
const onJoinGroup = () => {
|
||||||
|
joinGroup(group, currentUser.id).catch(() => {
|
||||||
|
toast.error('Failed to join group')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const onLeaveGroup = () => {
|
||||||
|
leaveGroup(group, currentUser.id).catch(() => {
|
||||||
|
toast.error('Failed to leave group')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
if (isMember) {
|
if (isMember) {
|
||||||
return (
|
return (
|
||||||
|
|
|
@ -2,16 +2,21 @@ import { useEffect, useState } from 'react'
|
||||||
import { Group } from 'common/group'
|
import { Group } from 'common/group'
|
||||||
import { User } from 'common/user'
|
import { User } from 'common/user'
|
||||||
import {
|
import {
|
||||||
|
GroupMemberDoc,
|
||||||
|
groupMembers,
|
||||||
listenForGroup,
|
listenForGroup,
|
||||||
|
listenForGroupContractDocs,
|
||||||
listenForGroups,
|
listenForGroups,
|
||||||
|
listenForMemberGroupIds,
|
||||||
listenForMemberGroups,
|
listenForMemberGroups,
|
||||||
listenForOpenGroups,
|
listenForOpenGroups,
|
||||||
listGroups,
|
listGroups,
|
||||||
} from 'web/lib/firebase/groups'
|
} from 'web/lib/firebase/groups'
|
||||||
import { getUser, getUsers } from 'web/lib/firebase/users'
|
import { getUser } from 'web/lib/firebase/users'
|
||||||
import { filterDefined } from 'common/util/array'
|
import { filterDefined } from 'common/util/array'
|
||||||
import { Contract } from 'common/contract'
|
import { Contract } from 'common/contract'
|
||||||
import { uniq } from 'lodash'
|
import { uniq } from 'lodash'
|
||||||
|
import { listenForValues } from 'web/lib/firebase/utils'
|
||||||
|
|
||||||
export const useGroup = (groupId: string | undefined) => {
|
export const useGroup = (groupId: string | undefined) => {
|
||||||
const [group, setGroup] = useState<Group | null | undefined>()
|
const [group, setGroup] = useState<Group | null | undefined>()
|
||||||
|
@ -43,29 +48,12 @@ export const useOpenGroups = () => {
|
||||||
return groups
|
return groups
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useMemberGroups = (
|
export const useMemberGroups = (userId: string | null | undefined) => {
|
||||||
userId: string | null | undefined,
|
|
||||||
options?: { withChatEnabled: boolean },
|
|
||||||
sort?: { by: 'mostRecentChatActivityTime' | 'mostRecentContractAddedTime' }
|
|
||||||
) => {
|
|
||||||
const [memberGroups, setMemberGroups] = useState<Group[] | undefined>()
|
const [memberGroups, setMemberGroups] = useState<Group[] | undefined>()
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (userId)
|
if (userId)
|
||||||
return listenForMemberGroups(
|
return listenForMemberGroups(userId, (groups) => setMemberGroups(groups))
|
||||||
userId,
|
}, [userId])
|
||||||
(groups) => {
|
|
||||||
if (options?.withChatEnabled)
|
|
||||||
return setMemberGroups(
|
|
||||||
filterDefined(
|
|
||||||
groups.filter((group) => group.chatDisabled !== true)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return setMemberGroups(groups)
|
|
||||||
},
|
|
||||||
sort
|
|
||||||
)
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [options?.withChatEnabled, sort?.by, userId])
|
|
||||||
return memberGroups
|
return memberGroups
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -77,16 +65,8 @@ export const useMemberGroupIds = (user: User | null | undefined) => {
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (user) {
|
if (user) {
|
||||||
const key = `member-groups-${user.id}`
|
return listenForMemberGroupIds(user.id, (groupIds) => {
|
||||||
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)
|
setMemberGroupIds(groupIds)
|
||||||
localStorage.setItem(key, JSON.stringify(groupIds))
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}, [user])
|
}, [user])
|
||||||
|
@ -94,26 +74,29 @@ export const useMemberGroupIds = (user: User | null | undefined) => {
|
||||||
return memberGroupIds
|
return memberGroupIds
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useMembers(group: Group, max?: number) {
|
export function useMembers(groupId: string | undefined) {
|
||||||
const [members, setMembers] = useState<User[]>([])
|
const [members, setMembers] = useState<User[]>([])
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const { memberIds } = group
|
if (groupId)
|
||||||
if (memberIds.length > 0) {
|
listenForValues<GroupMemberDoc>(groupMembers(groupId), (memDocs) => {
|
||||||
listMembers(group, max).then((members) => setMembers(members))
|
const memberIds = memDocs.map((memDoc) => memDoc.userId)
|
||||||
}
|
Promise.all(memberIds.map((id) => getUser(id))).then((users) => {
|
||||||
}, [group, max])
|
setMembers(users)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}, [groupId])
|
||||||
return members
|
return members
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listMembers(group: Group, max?: number) {
|
export function useMemberIds(groupId: string | null) {
|
||||||
const { memberIds } = group
|
const [memberIds, setMemberIds] = useState<string[]>([])
|
||||||
const numToRetrieve = max ?? memberIds.length
|
useEffect(() => {
|
||||||
if (memberIds.length === 0) return []
|
if (groupId)
|
||||||
if (numToRetrieve > 100)
|
return listenForValues<GroupMemberDoc>(groupMembers(groupId), (docs) => {
|
||||||
return (await getUsers()).filter((user) =>
|
setMemberIds(docs.map((doc) => doc.userId))
|
||||||
group.memberIds.includes(user.id)
|
})
|
||||||
)
|
}, [groupId])
|
||||||
return await Promise.all(group.memberIds.slice(0, numToRetrieve).map(getUser))
|
return memberIds
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useGroupsWithContract = (contract: Contract) => {
|
export const useGroupsWithContract = (contract: Contract) => {
|
||||||
|
@ -128,3 +111,16 @@ export const useGroupsWithContract = (contract: Contract) => {
|
||||||
|
|
||||||
return groups
|
return groups
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useGroupContractIds(groupId: string) {
|
||||||
|
const [contractIds, setContractIds] = useState<string[]>([])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (groupId)
|
||||||
|
return listenForGroupContractDocs(groupId, (docs) =>
|
||||||
|
setContractIds(docs.map((doc) => doc.contractId))
|
||||||
|
)
|
||||||
|
}, [groupId])
|
||||||
|
|
||||||
|
return contractIds
|
||||||
|
}
|
||||||
|
|
|
@ -1,13 +1,17 @@
|
||||||
import {
|
import {
|
||||||
|
collection,
|
||||||
|
collectionGroup,
|
||||||
deleteDoc,
|
deleteDoc,
|
||||||
deleteField,
|
deleteField,
|
||||||
doc,
|
doc,
|
||||||
getDocs,
|
getDocs,
|
||||||
|
onSnapshot,
|
||||||
query,
|
query,
|
||||||
|
setDoc,
|
||||||
updateDoc,
|
updateDoc,
|
||||||
where,
|
where,
|
||||||
} from 'firebase/firestore'
|
} from 'firebase/firestore'
|
||||||
import { sortBy, uniq } from 'lodash'
|
import { uniq } from 'lodash'
|
||||||
import { Group, GROUP_CHAT_SLUG, GroupLink } from 'common/group'
|
import { Group, GROUP_CHAT_SLUG, GroupLink } from 'common/group'
|
||||||
import {
|
import {
|
||||||
coll,
|
coll,
|
||||||
|
@ -18,8 +22,15 @@ import {
|
||||||
} from './utils'
|
} from './utils'
|
||||||
import { Contract } from 'common/contract'
|
import { Contract } from 'common/contract'
|
||||||
import { updateContract } from 'web/lib/firebase/contracts'
|
import { updateContract } from 'web/lib/firebase/contracts'
|
||||||
|
import { db } from 'web/lib/firebase/init'
|
||||||
|
import { filterDefined } from 'common/util/array'
|
||||||
|
import { getUser } from 'web/lib/firebase/users'
|
||||||
|
|
||||||
export const groups = coll<Group>('groups')
|
export const groups = coll<Group>('groups')
|
||||||
|
export const groupMembers = (groupId: string) =>
|
||||||
|
collection(groups, groupId, 'groupMembers')
|
||||||
|
export const groupContracts = (groupId: string) =>
|
||||||
|
collection(groups, groupId, 'groupContracts')
|
||||||
|
|
||||||
export function groupPath(
|
export function groupPath(
|
||||||
groupSlug: string,
|
groupSlug: string,
|
||||||
|
@ -33,6 +44,9 @@ export function groupPath(
|
||||||
return `/group/${groupSlug}${subpath ? `/${subpath}` : ''}`
|
return `/group/${groupSlug}${subpath ? `/${subpath}` : ''}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type GroupContractDoc = { contractId: string; createdTime: number }
|
||||||
|
export type GroupMemberDoc = { userId: string; createdTime: number }
|
||||||
|
|
||||||
export function updateGroup(group: Group, updates: Partial<Group>) {
|
export function updateGroup(group: Group, updates: Partial<Group>) {
|
||||||
return updateDoc(doc(groups, group.id), updates)
|
return updateDoc(doc(groups, group.id), updates)
|
||||||
}
|
}
|
||||||
|
@ -57,6 +71,13 @@ export function listenForGroups(setGroups: (groups: Group[]) => void) {
|
||||||
return listenForValues(groups, setGroups)
|
return listenForValues(groups, setGroups)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function listenForGroupContractDocs(
|
||||||
|
groupId: string,
|
||||||
|
setContractDocs: (docs: GroupContractDoc[]) => void
|
||||||
|
) {
|
||||||
|
return listenForValues(groupContracts(groupId), setContractDocs)
|
||||||
|
}
|
||||||
|
|
||||||
export function listenForOpenGroups(setGroups: (groups: Group[]) => void) {
|
export function listenForOpenGroups(setGroups: (groups: Group[]) => void) {
|
||||||
return listenForValues(
|
return listenForValues(
|
||||||
query(groups, where('anyoneCanJoin', '==', true)),
|
query(groups, where('anyoneCanJoin', '==', true)),
|
||||||
|
@ -68,6 +89,12 @@ export function getGroup(groupId: string) {
|
||||||
return getValue<Group>(doc(groups, groupId))
|
return getValue<Group>(doc(groups, groupId))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getGroupContracts(groupId: string) {
|
||||||
|
return getValues<{ contractId: string; createdTime: number }>(
|
||||||
|
groupContracts(groupId)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export async function getGroupBySlug(slug: string) {
|
export async function getGroupBySlug(slug: string) {
|
||||||
const q = query(groups, where('slug', '==', slug))
|
const q = query(groups, where('slug', '==', slug))
|
||||||
const docs = (await getDocs(q)).docs
|
const docs = (await getDocs(q)).docs
|
||||||
|
@ -81,33 +108,32 @@ export function listenForGroup(
|
||||||
return listenForValue(doc(groups, groupId), setGroup)
|
return listenForValue(doc(groups, groupId), setGroup)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function listenForMemberGroups(
|
export function listenForMemberGroupIds(
|
||||||
userId: string,
|
userId: string,
|
||||||
setGroups: (groups: Group[]) => void,
|
setGroupIds: (groupIds: string[]) => void
|
||||||
sort?: { by: 'mostRecentChatActivityTime' | 'mostRecentContractAddedTime' }
|
|
||||||
) {
|
) {
|
||||||
const q = query(groups, where('memberIds', 'array-contains', userId))
|
const q = query(
|
||||||
const sorter = (group: Group) => {
|
collectionGroup(db, 'groupMembers'),
|
||||||
if (sort?.by === 'mostRecentChatActivityTime') {
|
where('userId', '==', userId)
|
||||||
return group.mostRecentChatActivityTime ?? group.createdTime
|
)
|
||||||
}
|
return onSnapshot(q, { includeMetadataChanges: true }, (snapshot) => {
|
||||||
if (sort?.by === 'mostRecentContractAddedTime') {
|
if (snapshot.metadata.fromCache) return
|
||||||
return group.mostRecentContractAddedTime ?? group.createdTime
|
|
||||||
}
|
const values = snapshot.docs.map((doc) => doc.ref.parent.parent?.id)
|
||||||
return group.mostRecentActivityTime
|
|
||||||
}
|
setGroupIds(filterDefined(values))
|
||||||
return listenForValues<Group>(q, (groups) => {
|
|
||||||
const sorted = sortBy(groups, [(group) => -sorter(group)])
|
|
||||||
setGroups(sorted)
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listenForGroupsWithContractId(
|
export function listenForMemberGroups(
|
||||||
contractId: string,
|
userId: string,
|
||||||
setGroups: (groups: Group[]) => void
|
setGroups: (groups: Group[]) => void
|
||||||
) {
|
) {
|
||||||
const q = query(groups, where('contractIds', 'array-contains', contractId))
|
return listenForMemberGroupIds(userId, (groupIds) => {
|
||||||
return listenForValues<Group>(q, setGroups)
|
return Promise.all(groupIds.map(getGroup)).then((groups) => {
|
||||||
|
setGroups(filterDefined(groups))
|
||||||
|
})
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function addUserToGroupViaId(groupId: string, userId: string) {
|
export async function addUserToGroupViaId(groupId: string, userId: string) {
|
||||||
|
@ -121,19 +147,18 @@ export async function addUserToGroupViaId(groupId: string, userId: string) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function joinGroup(group: Group, userId: string): Promise<void> {
|
export async function joinGroup(group: Group, userId: string): Promise<void> {
|
||||||
const { memberIds } = group
|
// create a new member document in grouoMembers collection
|
||||||
if (memberIds.includes(userId)) return // already a member
|
const memberDoc = doc(groupMembers(group.id), userId)
|
||||||
|
return await setDoc(memberDoc, {
|
||||||
const newMemberIds = [...memberIds, userId]
|
userId,
|
||||||
return await updateGroup(group, { memberIds: uniq(newMemberIds) })
|
createdTime: Date.now(),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function leaveGroup(group: Group, userId: string): Promise<void> {
|
export async function leaveGroup(group: Group, userId: string): Promise<void> {
|
||||||
const { memberIds } = group
|
// delete the member document in groupMembers collection
|
||||||
if (!memberIds.includes(userId)) return // not a member
|
const memberDoc = doc(groupMembers(group.id), userId)
|
||||||
|
return await deleteDoc(memberDoc)
|
||||||
const newMemberIds = memberIds.filter((id) => id !== userId)
|
|
||||||
return await updateGroup(group, { memberIds: uniq(newMemberIds) })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function addContractToGroup(
|
export async function addContractToGroup(
|
||||||
|
@ -141,7 +166,6 @@ export async function addContractToGroup(
|
||||||
contract: Contract,
|
contract: Contract,
|
||||||
userId: string
|
userId: string
|
||||||
) {
|
) {
|
||||||
if (!canModifyGroupContracts(group, userId)) return
|
|
||||||
const newGroupLinks = [
|
const newGroupLinks = [
|
||||||
...(contract.groupLinks ?? []),
|
...(contract.groupLinks ?? []),
|
||||||
{
|
{
|
||||||
|
@ -158,25 +182,18 @@ export async function addContractToGroup(
|
||||||
groupLinks: newGroupLinks,
|
groupLinks: newGroupLinks,
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!group.contractIds.includes(contract.id)) {
|
// create new contract document in groupContracts collection
|
||||||
return await updateGroup(group, {
|
const contractDoc = doc(groupContracts(group.id), contract.id)
|
||||||
contractIds: uniq([...group.contractIds, contract.id]),
|
await setDoc(contractDoc, {
|
||||||
})
|
contractId: contract.id,
|
||||||
.then(() => group)
|
createdTime: Date.now(),
|
||||||
.catch((err) => {
|
})
|
||||||
console.error('error adding contract to group', err)
|
|
||||||
return err
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function removeContractFromGroup(
|
export async function removeContractFromGroup(
|
||||||
group: Group,
|
group: Group,
|
||||||
contract: Contract,
|
contract: Contract
|
||||||
userId: string
|
|
||||||
) {
|
) {
|
||||||
if (!canModifyGroupContracts(group, userId)) return
|
|
||||||
|
|
||||||
if (contract.groupLinks?.map((l) => l.groupId).includes(group.id)) {
|
if (contract.groupLinks?.map((l) => l.groupId).includes(group.id)) {
|
||||||
const newGroupLinks = contract.groupLinks?.filter(
|
const newGroupLinks = contract.groupLinks?.filter(
|
||||||
(link) => link.slug !== group.slug
|
(link) => link.slug !== group.slug
|
||||||
|
@ -188,25 +205,9 @@ export async function removeContractFromGroup(
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
if (group.contractIds.includes(contract.id)) {
|
// delete the contract document in groupContracts collection
|
||||||
const newContractIds = group.contractIds.filter((id) => id !== contract.id)
|
const contractDoc = doc(groupContracts(group.id), contract.id)
|
||||||
return await updateGroup(group, {
|
await deleteDoc(contractDoc)
|
||||||
contractIds: uniq(newContractIds),
|
|
||||||
})
|
|
||||||
.then(() => group)
|
|
||||||
.catch((err) => {
|
|
||||||
console.error('error removing contract from group', err)
|
|
||||||
return err
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function canModifyGroupContracts(group: Group, userId: string) {
|
|
||||||
return (
|
|
||||||
group.creatorId === userId ||
|
|
||||||
group.memberIds.includes(userId) ||
|
|
||||||
group.anyoneCanJoin
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getGroupLinkToDisplay(contract: Contract) {
|
export function getGroupLinkToDisplay(contract: Contract) {
|
||||||
|
@ -222,3 +223,8 @@ export function getGroupLinkToDisplay(contract: Contract) {
|
||||||
: sortedGroupLinks?.[0] ?? null
|
: sortedGroupLinks?.[0] ?? null
|
||||||
return groupToDisplay
|
return groupToDisplay
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function listMembers(group: Group) {
|
||||||
|
const members = await getValues<GroupMemberDoc>(groupMembers(group.id))
|
||||||
|
return await Promise.all(members.map((m) => m.userId).map(getUser))
|
||||||
|
}
|
||||||
|
|
|
@ -20,7 +20,7 @@ import {
|
||||||
import { formatMoney } from 'common/util/format'
|
import { formatMoney } from 'common/util/format'
|
||||||
import { removeUndefinedProps } from 'common/util/object'
|
import { removeUndefinedProps } from 'common/util/object'
|
||||||
import { ChoicesToggleGroup } from 'web/components/choices-toggle-group'
|
import { ChoicesToggleGroup } from 'web/components/choices-toggle-group'
|
||||||
import { canModifyGroupContracts, getGroup } from 'web/lib/firebase/groups'
|
import { getGroup } from 'web/lib/firebase/groups'
|
||||||
import { Group } from 'common/group'
|
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'
|
||||||
|
@ -139,7 +139,7 @@ export function NewContract(props: {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (groupId)
|
if (groupId)
|
||||||
getGroup(groupId).then((group) => {
|
getGroup(groupId).then((group) => {
|
||||||
if (group && canModifyGroupContracts(group, creator.id)) {
|
if (group) {
|
||||||
setSelectedGroup(group)
|
setSelectedGroup(group)
|
||||||
setShowGroupSelector(false)
|
setShowGroupSelector(false)
|
||||||
}
|
}
|
||||||
|
|
|
@ -14,13 +14,14 @@ import {
|
||||||
getGroupBySlug,
|
getGroupBySlug,
|
||||||
groupPath,
|
groupPath,
|
||||||
joinGroup,
|
joinGroup,
|
||||||
|
listMembers,
|
||||||
updateGroup,
|
updateGroup,
|
||||||
} from 'web/lib/firebase/groups'
|
} from 'web/lib/firebase/groups'
|
||||||
import { Row } from 'web/components/layout/row'
|
import { Row } from 'web/components/layout/row'
|
||||||
import { firebaseLogin, getUser, User } from 'web/lib/firebase/users'
|
import { firebaseLogin, getUser, User } from 'web/lib/firebase/users'
|
||||||
import { Col } from 'web/components/layout/col'
|
import { Col } from 'web/components/layout/col'
|
||||||
import { useUser } from 'web/hooks/use-user'
|
import { useUser } from 'web/hooks/use-user'
|
||||||
import { listMembers, useGroup, useMembers } from 'web/hooks/use-group'
|
import { useGroup, useGroupContractIds, useMembers } from 'web/hooks/use-group'
|
||||||
import { scoreCreators, scoreTraders } from 'common/scoring'
|
import { scoreCreators, scoreTraders } from 'common/scoring'
|
||||||
import { Leaderboard } from 'web/components/leaderboard'
|
import { Leaderboard } from 'web/components/leaderboard'
|
||||||
import { formatMoney } from 'common/util/format'
|
import { formatMoney } from 'common/util/format'
|
||||||
|
@ -157,7 +158,6 @@ export default function GroupPage(props: {
|
||||||
const {
|
const {
|
||||||
contractsCount,
|
contractsCount,
|
||||||
creator,
|
creator,
|
||||||
members,
|
|
||||||
traderScores,
|
traderScores,
|
||||||
topTraders,
|
topTraders,
|
||||||
creatorScores,
|
creatorScores,
|
||||||
|
@ -174,6 +174,7 @@ export default function GroupPage(props: {
|
||||||
|
|
||||||
const user = useUser()
|
const user = useUser()
|
||||||
const isAdmin = useAdmin()
|
const isAdmin = useAdmin()
|
||||||
|
const members = useMembers(group?.id) ?? props.members
|
||||||
|
|
||||||
useSaveReferral(user, {
|
useSaveReferral(user, {
|
||||||
defaultReferrerUsername: creator.username,
|
defaultReferrerUsername: creator.username,
|
||||||
|
@ -183,9 +184,8 @@ export default function GroupPage(props: {
|
||||||
if (group === null || !groupSubpages.includes(page) || slugs[2]) {
|
if (group === null || !groupSubpages.includes(page) || slugs[2]) {
|
||||||
return <Custom404 />
|
return <Custom404 />
|
||||||
}
|
}
|
||||||
const { memberIds } = group
|
|
||||||
const isCreator = user && group && user.id === group.creatorId
|
const isCreator = user && group && user.id === group.creatorId
|
||||||
const isMember = user && memberIds.includes(user.id)
|
const isMember = user && members.map((m) => m.id).includes(user.id)
|
||||||
|
|
||||||
const leaderboard = (
|
const leaderboard = (
|
||||||
<Col>
|
<Col>
|
||||||
|
@ -347,8 +347,7 @@ function GroupOverview(props: {
|
||||||
{isCreator ? (
|
{isCreator ? (
|
||||||
<EditGroupButton className={'ml-1'} group={group} />
|
<EditGroupButton className={'ml-1'} group={group} />
|
||||||
) : (
|
) : (
|
||||||
user &&
|
user && (
|
||||||
group.memberIds.includes(user?.id) && (
|
|
||||||
<Row>
|
<Row>
|
||||||
<JoinOrLeaveGroupButton group={group} />
|
<JoinOrLeaveGroupButton group={group} />
|
||||||
</Row>
|
</Row>
|
||||||
|
@ -425,7 +424,7 @@ function GroupMemberSearch(props: { members: User[]; group: Group }) {
|
||||||
let { members } = props
|
let { members } = props
|
||||||
|
|
||||||
// Use static members on load, but also listen to member changes:
|
// Use static members on load, but also listen to member changes:
|
||||||
const listenToMembers = useMembers(group)
|
const listenToMembers = useMembers(group.id)
|
||||||
if (listenToMembers) {
|
if (listenToMembers) {
|
||||||
members = listenToMembers
|
members = listenToMembers
|
||||||
}
|
}
|
||||||
|
@ -547,6 +546,7 @@ function AddContractButton(props: { group: Group; user: User }) {
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
const [contracts, setContracts] = useState<Contract[]>([])
|
const [contracts, setContracts] = useState<Contract[]>([])
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
|
const groupContractIds = useGroupContractIds(group.id)
|
||||||
|
|
||||||
async function addContractToCurrentGroup(contract: Contract) {
|
async function addContractToCurrentGroup(contract: Contract) {
|
||||||
if (contracts.map((c) => c.id).includes(contract.id)) {
|
if (contracts.map((c) => c.id).includes(contract.id)) {
|
||||||
|
@ -634,7 +634,9 @@ function AddContractButton(props: { group: Group; user: User }) {
|
||||||
hideOrderSelector={true}
|
hideOrderSelector={true}
|
||||||
onContractClick={addContractToCurrentGroup}
|
onContractClick={addContractToCurrentGroup}
|
||||||
cardHideOptions={{ hideGroupLink: true, hideQuickBet: true }}
|
cardHideOptions={{ hideGroupLink: true, hideQuickBet: true }}
|
||||||
additionalFilter={{ excludeContractIds: group.contractIds }}
|
additionalFilter={{
|
||||||
|
excludeContractIds: groupContractIds,
|
||||||
|
}}
|
||||||
highlightOptions={{
|
highlightOptions={{
|
||||||
contractIds: contracts.map((c) => c.id),
|
contractIds: contracts.map((c) => c.id),
|
||||||
highlightClassName: '!bg-indigo-100 border-indigo-100 border-2',
|
highlightClassName: '!bg-indigo-100 border-indigo-100 border-2',
|
||||||
|
@ -653,7 +655,7 @@ function JoinGroupButton(props: {
|
||||||
}) {
|
}) {
|
||||||
const { group, user } = props
|
const { group, user } = props
|
||||||
function addUserToGroup() {
|
function addUserToGroup() {
|
||||||
if (user && !group.memberIds.includes(user.id)) {
|
if (user) {
|
||||||
toast.promise(joinGroup(group, user.id), {
|
toast.promise(joinGroup(group, user.id), {
|
||||||
loading: 'Joining group...',
|
loading: 'Joining group...',
|
||||||
success: 'Joined group!',
|
success: 'Joined group!',
|
||||||
|
|
|
@ -7,7 +7,12 @@ import { Col } from 'web/components/layout/col'
|
||||||
import { Row } from 'web/components/layout/row'
|
import { Row } from 'web/components/layout/row'
|
||||||
import { Page } from 'web/components/page'
|
import { Page } from 'web/components/page'
|
||||||
import { Title } from 'web/components/title'
|
import { Title } from 'web/components/title'
|
||||||
import { useGroups, useMemberGroupIds, useMembers } from 'web/hooks/use-group'
|
import {
|
||||||
|
useGroupContractIds,
|
||||||
|
useGroups,
|
||||||
|
useMemberGroupIds,
|
||||||
|
useMemberIds,
|
||||||
|
} from 'web/hooks/use-group'
|
||||||
import { useUser } from 'web/hooks/use-user'
|
import { useUser } from 'web/hooks/use-user'
|
||||||
import { groupPath, listAllGroups } from 'web/lib/firebase/groups'
|
import { groupPath, listAllGroups } from 'web/lib/firebase/groups'
|
||||||
import { getUser, User } from 'web/lib/firebase/users'
|
import { getUser, User } from 'web/lib/firebase/users'
|
||||||
|
@ -18,7 +23,6 @@ import { Avatar } from 'web/components/avatar'
|
||||||
import { JoinOrLeaveGroupButton } from 'web/components/groups/groups-button'
|
import { JoinOrLeaveGroupButton } from 'web/components/groups/groups-button'
|
||||||
import { searchInAny } from 'common/util/parse'
|
import { searchInAny } from 'common/util/parse'
|
||||||
import { SEO } from 'web/components/SEO'
|
import { SEO } from 'web/components/SEO'
|
||||||
import { UserLink } from 'web/components/user-link'
|
|
||||||
|
|
||||||
export async function getStaticProps() {
|
export async function getStaticProps() {
|
||||||
let groups = await listAllGroups().catch((_) => [])
|
let groups = await listAllGroups().catch((_) => [])
|
||||||
|
@ -73,10 +77,7 @@ export default function Groups(props: {
|
||||||
|
|
||||||
// List groups with the highest question count, then highest member count
|
// List groups with the highest question count, then highest member count
|
||||||
// TODO use find-active-contracts to sort by?
|
// TODO use find-active-contracts to sort by?
|
||||||
const matches = sortBy(groups, [
|
const matches = sortBy(groups, []).filter((g) =>
|
||||||
(group) => -1 * group.contractIds.length,
|
|
||||||
(group) => -1 * group.memberIds.length,
|
|
||||||
]).filter((g) =>
|
|
||||||
searchInAny(
|
searchInAny(
|
||||||
query,
|
query,
|
||||||
g.name,
|
g.name,
|
||||||
|
@ -87,10 +88,7 @@ export default function Groups(props: {
|
||||||
|
|
||||||
const matchesOrderedByRecentActivity = sortBy(groups, [
|
const matchesOrderedByRecentActivity = sortBy(groups, [
|
||||||
(group) =>
|
(group) =>
|
||||||
-1 *
|
-1 * (group.mostRecentContractAddedTime ?? group.mostRecentActivityTime),
|
||||||
(group.mostRecentChatActivityTime ??
|
|
||||||
group.mostRecentContractAddedTime ??
|
|
||||||
group.mostRecentActivityTime),
|
|
||||||
]).filter((g) =>
|
]).filter((g) =>
|
||||||
searchInAny(
|
searchInAny(
|
||||||
query,
|
query,
|
||||||
|
@ -124,37 +122,6 @@ export default function Groups(props: {
|
||||||
<Tabs
|
<Tabs
|
||||||
currentPageForAnalytics={'groups'}
|
currentPageForAnalytics={'groups'}
|
||||||
tabs={[
|
tabs={[
|
||||||
...(user && memberGroupIds.length > 0
|
|
||||||
? [
|
|
||||||
{
|
|
||||||
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"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="flex flex-wrap justify-center gap-4">
|
|
||||||
{matchesOrderedByRecentActivity
|
|
||||||
.filter((match) =>
|
|
||||||
memberGroupIds.includes(match.id)
|
|
||||||
)
|
|
||||||
.map((group) => (
|
|
||||||
<GroupCard
|
|
||||||
key={group.id}
|
|
||||||
group={group}
|
|
||||||
creator={creatorsDict[group.creatorId]}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</Col>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
]
|
|
||||||
: []),
|
|
||||||
{
|
{
|
||||||
title: 'All',
|
title: 'All',
|
||||||
content: (
|
content: (
|
||||||
|
@ -178,6 +145,31 @@ export default function Groups(props: {
|
||||||
</Col>
|
</Col>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap justify-center gap-4">
|
||||||
|
{matchesOrderedByRecentActivity
|
||||||
|
.filter((match) => memberGroupIds.includes(match.id))
|
||||||
|
.map((group) => (
|
||||||
|
<GroupCard
|
||||||
|
key={group.id}
|
||||||
|
group={group}
|
||||||
|
creator={creatorsDict[group.creatorId]}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Col>
|
||||||
|
),
|
||||||
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</Col>
|
</Col>
|
||||||
|
@ -188,6 +180,7 @@ export default function Groups(props: {
|
||||||
|
|
||||||
export function GroupCard(props: { group: Group; creator: User | undefined }) {
|
export function GroupCard(props: { group: Group; creator: User | undefined }) {
|
||||||
const { group, creator } = props
|
const { group, creator } = props
|
||||||
|
const groupContracts = useGroupContractIds(group.id)
|
||||||
return (
|
return (
|
||||||
<Col className="relative min-w-[20rem] max-w-xs gap-1 rounded-xl bg-white p-8 shadow-md hover:bg-gray-100">
|
<Col className="relative min-w-[20rem] max-w-xs gap-1 rounded-xl bg-white p-8 shadow-md hover:bg-gray-100">
|
||||||
<Link href={groupPath(group.slug)}>
|
<Link href={groupPath(group.slug)}>
|
||||||
|
@ -205,7 +198,7 @@ export function GroupCard(props: { group: Group; creator: User | undefined }) {
|
||||||
<Row className="items-center justify-between gap-2">
|
<Row className="items-center justify-between gap-2">
|
||||||
<span className="text-xl">{group.name}</span>
|
<span className="text-xl">{group.name}</span>
|
||||||
</Row>
|
</Row>
|
||||||
<Row>{group.contractIds.length} questions</Row>
|
<Row>{groupContracts.length} questions</Row>
|
||||||
<Row className="text-sm text-gray-500">
|
<Row className="text-sm text-gray-500">
|
||||||
<GroupMembersList group={group} />
|
<GroupMembersList group={group} />
|
||||||
</Row>
|
</Row>
|
||||||
|
@ -221,23 +214,11 @@ export function GroupCard(props: { group: Group; creator: User | undefined }) {
|
||||||
|
|
||||||
function GroupMembersList(props: { group: Group }) {
|
function GroupMembersList(props: { group: Group }) {
|
||||||
const { group } = props
|
const { group } = props
|
||||||
const maxMembersToShow = 3
|
const memberIds = useMemberIds(group.id)
|
||||||
const members = useMembers(group, maxMembersToShow).filter(
|
if (memberIds.length === 1) return <div />
|
||||||
(m) => m.id !== group.creatorId
|
|
||||||
)
|
|
||||||
if (group.memberIds.length === 1) return <div />
|
|
||||||
return (
|
return (
|
||||||
<div className="text-neutral flex flex-wrap gap-1">
|
<div className="text-neutral flex flex-wrap gap-1">
|
||||||
<span className={'text-gray-500'}>Other members</span>
|
<span>{memberIds.length} members</span>
|
||||||
{members.slice(0, maxMembersToShow).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>
|
|
||||||
))}
|
|
||||||
{group.memberIds.length > maxMembersToShow && (
|
|
||||||
<span> & {group.memberIds.length - maxMembersToShow} more</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -122,7 +122,7 @@ export async function getStaticProps() {
|
||||||
const markets = Object.fromEntries(groups.map((g, i) => [g.id, contracts[i]]))
|
const markets = Object.fromEntries(groups.map((g, i) => [g.id, contracts[i]]))
|
||||||
|
|
||||||
const groupMap = keyBy(groups, 'id')
|
const groupMap = keyBy(groups, 'id')
|
||||||
const numPeople = mapValues(groupMap, (g) => g?.memberIds.length)
|
const numPeople = mapValues(groupMap, (g) => g?.totalMembers)
|
||||||
const slugs = mapValues(groupMap, 'slug')
|
const slugs = mapValues(groupMap, 'slug')
|
||||||
|
|
||||||
return { props: { markets, numPeople, slugs }, revalidate: 60 * 10 }
|
return { props: { markets, numPeople, slugs }, revalidate: 60 * 10 }
|
||||||
|
|
Loading…
Reference in New Issue
Block a user