diff --git a/common/group.ts b/common/group.ts index 181ad153..5c716dba 100644 --- a/common/group.ts +++ b/common/group.ts @@ -6,14 +6,16 @@ export type Group = { creatorId: string // User id createdTime: number mostRecentActivityTime: number - memberIds: string[] // User ids anyoneCanJoin: boolean - contractIds: string[] - + totalContracts: number + totalMembers: number aboutPostId?: string chatDisabled?: boolean - mostRecentChatActivityTime?: 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_ABOUT_LENGTH = 140 diff --git a/firestore.rules b/firestore.rules index e42e3ed7..15b60d0f 100644 --- a/firestore.rules +++ b/firestore.rules @@ -160,25 +160,40 @@ service cloud.firestore { .hasOnly(['isSeen', 'viewTime']); } - match /groups/{groupId} { + match /{somePath=**}/groupMembers/{memberId} { + allow read; + } + + match /{somePath=**}/groupContracts/{contractId} { + allow read; + } + + match /groups/{groupId} { allow read; allow update: if (request.auth.uid == resource.data.creatorId || isAdmin()) && request.resource.data.diff(resource.data) .affectedKeys() - .hasOnly(['name', 'about', 'contractIds', 'memberIds', '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' ]); + .hasOnly(['name', 'about', 'anyoneCanJoin', 'aboutPostId' ]); 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 /groupContracts/{contractId} { + 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} { 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} { diff --git a/functions/src/create-group.ts b/functions/src/create-group.ts index 71c6bd64..fc64aeff 100644 --- a/functions/src/create-group.ts +++ b/functions/src/create-group.ts @@ -58,13 +58,23 @@ export const creategroup = newEndpoint({}, async (req, auth) => { createdTime: Date.now(), mostRecentActivityTime: Date.now(), // TODO: allow users to add contract ids on group creation - contractIds: [], anyoneCanJoin, - memberIds, + totalContracts: 0, + totalMembers: memberIds.length, } 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 } }) diff --git a/functions/src/create-market.ts b/functions/src/create-market.ts index e9804f90..300d91f2 100644 --- a/functions/src/create-market.ts +++ b/functions/src/create-market.ts @@ -155,8 +155,14 @@ export const createmarket = newEndpoint({}, async (req, auth) => { } 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 ( - !group.memberIds.includes(user.id) && + !groupMemberDocs.map((m) => m.userId).includes(user.id) && !group.anyoneCanJoin && group.creatorId !== user.id ) { @@ -227,11 +233,20 @@ export const createmarket = newEndpoint({}, async (req, auth) => { await contractRef.create(contract) 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) - const groupDocRef = firestore.collection('groups').doc(group.id) - groupDocRef.update({ - contractIds: uniq([...group.contractIds, contractRef.id]), + const groupContractRef = firestore + .collection(`groups/${groupId}/groupContracts`) + .doc(contract.id) + await groupContractRef.set({ + contractId: contract.id, + createdTime: Date.now(), }) } } diff --git a/functions/src/create-user.ts b/functions/src/create-user.ts index 35394e90..eabe0fd0 100644 --- a/functions/src/create-user.ts +++ b/functions/src/create-user.ts @@ -1,6 +1,5 @@ import * as admin from 'firebase-admin' import { z } from 'zod' -import { uniq } from 'lodash' import { PrivateUser, User } from '../../common/user' import { getUser, getUserByUsername, getValues } from './utils' @@ -17,7 +16,7 @@ import { import { track } from './analytics' 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' const bodySchema = z.object({ @@ -117,23 +116,8 @@ const addUserToDefaultGroups = async (user: User) => { firestore.collection('groups').where('slug', '==', slug) ) await firestore - .collection('groups') - .doc(groups[0].id) - .update({ - memberIds: uniq(groups[0].memberIds.concat(user.id)), - }) - } - - for (const slug of NEW_USER_GROUP_SLUGS) { - const groups = await getValues( - 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)), - }) + .collection(`groups/${groups[0].id}/groupMembers`) + .doc(user.id) + .set({ userId: user.id, createdTime: Date.now() }) } } diff --git a/functions/src/index.ts b/functions/src/index.ts index 9a5ec872..be73b6af 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -21,9 +21,7 @@ export * from './on-follow-user' export * from './on-unfollow-user' export * from './on-create-liquidity-provision' export * from './on-update-group' -export * from './on-create-group' export * from './on-update-user' -export * from './on-create-comment-on-group' export * from './on-create-txn' export * from './on-delete-group' export * from './score-contracts' diff --git a/functions/src/on-create-comment-on-group.ts b/functions/src/on-create-comment-on-group.ts deleted file mode 100644 index 15f2bbc1..00000000 --- a/functions/src/on-create-comment-on-group.ts +++ /dev/null @@ -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 - ) - }) - ) - }) diff --git a/functions/src/on-create-group.ts b/functions/src/on-create-group.ts deleted file mode 100644 index 5209788d..00000000 --- a/functions/src/on-create-group.ts +++ /dev/null @@ -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, - } - ) - }) diff --git a/functions/src/on-update-group.ts b/functions/src/on-update-group.ts index 7e6a5697..93fb5550 100644 --- a/functions/src/on-update-group.ts +++ b/functions/src/on-update-group.ts @@ -15,21 +15,68 @@ export const onUpdateGroup = functions.firestore if (prevGroup.mostRecentActivityTime !== group.mostRecentActivityTime) 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 .collection('groups') .doc(group.id) .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[]) { for (const contractId of contractIds) { const contract = await getContract(contractId) diff --git a/functions/src/scripts/convert-categories.ts b/functions/src/scripts/convert-categories.ts deleted file mode 100644 index 3436bcbc..00000000 --- a/functions/src/scripts/convert-categories.ts +++ /dev/null @@ -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( - adminFirestore - .collection('contracts') - .where('lowercaseTags', 'array-contains', category.toLowerCase()) - ) - const slug = category.toLowerCase() + CATEGORIES_GROUP_SLUG_POSTFIX - const oldGroup = await getValues( - 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(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) -} diff --git a/functions/src/scripts/convert-tag-to-group.ts b/functions/src/scripts/convert-tag-to-group.ts index 48f14e27..3240357e 100644 --- a/functions/src/scripts/convert-tag-to-group.ts +++ b/functions/src/scripts/convert-tag-to-group.ts @@ -4,21 +4,23 @@ import * as admin from 'firebase-admin' import { initAdmin } from './script-init' import { isProd, log } from '../utils' 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 results = await firestore .collection('contracts') .where('lowercaseTags', 'array-contains', tag.toLowerCase()) .get() - return results.docs.map((d) => d.id) + return results.docs.map((d) => d.data() as Contract) } const createGroup = async ( name: string, about: string, - contractIds: string[] + contracts: Contract[] ) => { const firestore = admin.firestore() const creatorId = isProd() @@ -36,21 +38,60 @@ const createGroup = async ( about, createdTime: now, mostRecentActivityTime: now, - contractIds: contractIds, 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) => { log(`Looking up contract IDs with tag ${tag}...`) - const contractIds = await getTaggedContractIds(tag) - log(`${contractIds.length} contracts found.`) - if (contractIds.length > 0) { + const contracts = await getTaggedContracts(tag) + log(`${contracts.length} contracts found.`) + if (contracts.length > 0) { log(`Creating group ${groupName}...`) 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) } } diff --git a/functions/src/scripts/update-groups.ts b/functions/src/scripts/update-groups.ts new file mode 100644 index 00000000..952a0d55 --- /dev/null +++ b/functions/src/scripts/update-groups.ts @@ -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() +} diff --git a/web/components/contract-search.tsx b/web/components/contract-search.tsx index f8b7622e..a0396d2e 100644 --- a/web/components/contract-search.tsx +++ b/web/components/contract-search.tsx @@ -282,8 +282,8 @@ function ContractSearchControls(props: { : DEFAULT_CATEGORY_GROUPS.map((g) => g.slug) const memberPillGroups = sortBy( - memberGroups.filter((group) => group.contractIds.length > 0), - (group) => group.contractIds.length + memberGroups.filter((group) => group.totalContracts > 0), + (group) => group.totalContracts ).reverse() const pillGroups: { name: string; slug: string }[] = diff --git a/web/components/groups/contract-groups-list.tsx b/web/components/groups/contract-groups-list.tsx index 7bbcfa7c..d39a35d3 100644 --- a/web/components/groups/contract-groups-list.tsx +++ b/web/components/groups/contract-groups-list.tsx @@ -7,13 +7,13 @@ import { Button } from 'web/components/button' import { GroupSelector } from 'web/components/groups/group-selector' import { addContractToGroup, - canModifyGroupContracts, removeContractFromGroup, } from 'web/lib/firebase/groups' import { User } from 'common/user' import { Contract } from 'common/contract' 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: { contract: Contract @@ -22,6 +22,15 @@ export function ContractGroupsList(props: { const { user, contract } = props const { groupLinks } = 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 ( @@ -61,7 +70,7 @@ export function ContractGroupsList(props: { diff --git a/web/components/groups/edit-group-button.tsx b/web/components/groups/edit-group-button.tsx index 834af5ec..6349ad3f 100644 --- a/web/components/groups/edit-group-button.tsx +++ b/web/components/groups/edit-group-button.tsx @@ -3,17 +3,16 @@ import clsx from 'clsx' import { PencilIcon } from '@heroicons/react/outline' 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 { useRouter } from 'next/router' import { Modal } from 'web/components/layout/modal' import { FilterSelectUsers } from 'web/components/filter-select-users' import { User } from 'common/user' -import { uniq } from 'lodash' +import { useMemberIds } from 'web/hooks/use-group' export function EditGroupButton(props: { group: Group; className?: string }) { const { group, className } = props - const { memberIds } = group const router = useRouter() const [name, setName] = useState(group.name) @@ -21,7 +20,7 @@ export function EditGroupButton(props: { group: Group; className?: string }) { const [open, setOpen] = useState(false) const [isSubmitting, setIsSubmitting] = useState(false) const [addMemberUsers, setAddMemberUsers] = useState([]) - + const memberIds = useMemberIds(group.id) function updateOpen(newOpen: boolean) { setAddMemberUsers([]) setOpen(newOpen) @@ -33,11 +32,7 @@ export function EditGroupButton(props: { group: Group; className?: string }) { const onSubmit = async () => { setIsSubmitting(true) - await updateGroup(group, { - name, - about, - memberIds: uniq([...memberIds, ...addMemberUsers.map((user) => user.id)]), - }) + await Promise.all(addMemberUsers.map((user) => joinGroup(group, user.id))) setIsSubmitting(false) updateOpen(false) diff --git a/web/components/groups/group-chat.tsx b/web/components/groups/group-chat.tsx deleted file mode 100644 index 9a60c9c7..00000000 --- a/web/components/groups/group-chat.tsx +++ /dev/null @@ -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(null) - const [scrollToMessageId, setScrollToMessageId] = useState('') - const [scrollToMessageRef, setScrollToMessageRef] = - useState(null) - const [replyToUser, setReplyToUser] = useState() - - const router = useRouter() - const isMember = user && group.memberIds.includes(user?.id) - - const { width, height } = useWindowSize() - const [containerRef, setContainerRef] = useState(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 ( - - - {groupedMessages.map((messages) => ( - - ))} - {messages.length === 0 && ( -
- No messages yet. Why not{isMember ? ` ` : ' join and '} - -
- )} - - {user && group.memberIds.includes(user.id) && ( -
-
- -
-
- -
-
- )} - - {privateUser && ( -