diff --git a/libs/model/src/comment/CreateComment.command.ts b/libs/model/src/comment/CreateComment.command.ts index a0422b3983b..61baa501415 100644 --- a/libs/model/src/comment/CreateComment.command.ts +++ b/libs/model/src/comment/CreateComment.command.ts @@ -32,7 +32,9 @@ export function CreateComment(): Command< return { ...schemas.CreateComment, auth: [ - isAuthorized({ action: schemas.PermissionEnum.CREATE_COMMENT }), + isAuthorized({ + action: schemas.PermissionEnum.CREATE_COMMENT, + }), verifyCommentSignature, ], body: async ({ actor, payload, auth }) => { diff --git a/libs/model/src/comment/CreateCommentReaction.command.ts b/libs/model/src/comment/CreateCommentReaction.command.ts index 412e87a0b3f..89af9a81ab4 100644 --- a/libs/model/src/comment/CreateCommentReaction.command.ts +++ b/libs/model/src/comment/CreateCommentReaction.command.ts @@ -13,7 +13,9 @@ export function CreateCommentReaction(): Command< return { ...schemas.CreateCommentReaction, auth: [ - isAuthorized({ action: schemas.PermissionEnum.CREATE_COMMENT_REACTION }), + isAuthorized({ + action: schemas.PermissionEnum.CREATE_COMMENT_REACTION, + }), verifyReactionSignature, ], body: async ({ payload, actor, auth }) => { diff --git a/libs/model/src/community/CreateGroup.command.ts b/libs/model/src/community/CreateGroup.command.ts index f863cc1d8e6..947eb6c3f77 100644 --- a/libs/model/src/community/CreateGroup.command.ts +++ b/libs/model/src/community/CreateGroup.command.ts @@ -25,7 +25,7 @@ export function CreateGroup(): Command< const topics = await models.Topic.findAll({ where: { - id: { [Op.in]: payload.topics || [] }, + id: { [Op.in]: payload.topics?.map((t) => t.id) || [] }, community_id, }, }); @@ -70,6 +70,20 @@ export function CreateGroup(): Command< transaction, }, ); + + if (group.id) { + // add topic level interaction permissions for current group + const groupPermissions = (payload.topics || []).map((t) => ({ + group_id: group.id!, + topic_id: t.id, + allowed_actions: sequelize.literal( + `ARRAY[${t.permissions.map((p) => `'${p}'`).join(', ')}]::"enum_GroupPermissions_allowed_actions"[]`, + ) as unknown as schemas.PermissionEnum[], + })); + await models.GroupPermission.bulkCreate(groupPermissions, { + transaction, + }); + } } return group.toJSON(); }, diff --git a/libs/model/src/community/UpdateGroup.command.ts b/libs/model/src/community/UpdateGroup.command.ts index 25461556365..3af2203dcae 100644 --- a/libs/model/src/community/UpdateGroup.command.ts +++ b/libs/model/src/community/UpdateGroup.command.ts @@ -33,7 +33,7 @@ export function UpdateGroup(): Command< const topics = await models.Topic.findAll({ where: { - id: { [Op.in]: payload.topics || [] }, + id: { [Op.in]: payload.topics?.map((t) => t.id) || [] }, community_id, }, }); @@ -90,6 +90,26 @@ export function UpdateGroup(): Command< transaction, }, ); + + // update topic level interaction permissions for current group + await Promise.all( + (payload.topics || [])?.map(async (t) => { + if (group.id) { + await models.GroupPermission.update( + { + allowed_actions: t.permissions, + }, + { + where: { + group_id: group_id, + topic_id: t.id, + }, + transaction, + }, + ); + } + }), + ); } return group.toJSON(); diff --git a/libs/model/src/middleware/authorization.ts b/libs/model/src/middleware/authorization.ts index 2bd482c8378..2045377a3eb 100644 --- a/libs/model/src/middleware/authorization.ts +++ b/libs/model/src/middleware/authorization.ts @@ -6,7 +6,10 @@ import { type Context, type Handler, } from '@hicommonwealth/core'; -import { Group, GroupPermissionAction } from '@hicommonwealth/schemas'; +import { + Group, + GroupPermissionAction, +} from '@hicommonwealth/schemas'; import { Role } from '@hicommonwealth/shared'; import { Op, QueryTypes } from 'sequelize'; import { ZodSchema, z } from 'zod'; @@ -182,7 +185,7 @@ async function buildAuth( /** * Checks if actor passes a set of requirements and grants access for all groups of the given topic */ -async function isTopicMember( +async function hasTopicInteractionPermissions( actor: Actor, auth: AuthContext, action: GroupPermissionAction, @@ -194,39 +197,45 @@ async function isTopicMember( if (auth.topic.group_ids?.length === 0) return; + // check if user has permission to perform "action" in 'topic_id' + // the 'topic_id' can belong to any group where user has membership + // the group with 'topic_id' having higher permissions will take precedence const groups = await models.sequelize.query< z.infer & { allowed_actions?: GroupPermissionAction[]; } >( ` - SELECT g.*, gp.allowed_actions + SELECT + g.*, + gp.allowed_actions as allowed_actions FROM "Groups" as g - LEFT JOIN "GroupPermissions" gp ON g.id = gp.group_id - WHERE g.community_id = :community_id AND g.id IN (:group_ids); + LEFT JOIN "GroupPermissions" gp ON g.id = gp.group_id AND gp.topic_id = :topic_id + WHERE g.community_id = :community_id `, { type: QueryTypes.SELECT, raw: true, replacements: { community_id: auth.topic.community_id, - group_ids: auth.topic.group_ids, + topic_id: auth.topic.id, }, }, ); // There are 2 cases here. We either have the old group permission system where the group doesn't have - // any allowed_actions, or we have the new fine-grained permission system where the action must be in - // the allowed_actions list. - const allowed = groups.filter( + // any group_allowed_actions, or we have the new fine-grained permission system where the action must be in + // the group_allowed_actions list. + const allowedGroupActions = groups.filter( (g) => !g.allowed_actions || g.allowed_actions.includes(action), ); - if (!allowed.length!) throw new NonMember(actor, auth.topic.name, action); + if (!allowedGroupActions.length!) + throw new NonMember(actor, auth.topic.name, action); // check membership for all groups of topic const memberships = await models.Membership.findAll({ where: { - group_id: { [Op.in]: allowed.map((g) => g.id!) }, + group_id: { [Op.in]: allowedGroupActions.map((g) => g.id!) }, address_id: auth.address!.id, }, include: [ @@ -295,7 +304,11 @@ export function isAuthorized({ if (action) { // waterfall stops here after validating the action - await isTopicMember(ctx.actor, auth, action); + await hasTopicInteractionPermissions( + ctx.actor, + auth, + action, + ); return; } diff --git a/libs/model/src/models/associations.ts b/libs/model/src/models/associations.ts index 0ef84150c9f..d166462d2d5 100644 --- a/libs/model/src/models/associations.ts +++ b/libs/model/src/models/associations.ts @@ -89,7 +89,13 @@ export const buildAssociations = (db: DB) => { asMany: 'threads', onUpdate: 'CASCADE', onDelete: 'SET NULL', - }).withMany(db.ContestTopic, { asMany: 'contest_topics' }); + }) + .withMany(db.ContestTopic, { asMany: 'contest_topics' }) + .withMany(db.GroupPermission, { + foreignKey: 'topic_id', + onUpdate: 'CASCADE', + onDelete: 'CASCADE', + }); db.Thread.withMany(db.Poll) .withMany(db.ContestAction, { @@ -130,7 +136,11 @@ export const buildAssociations = (db: DB) => { onDelete: 'CASCADE', }); - db.Group.withMany(db.GroupPermission); + db.Group.withMany(db.GroupPermission, { + foreignKey: 'group_id', + onUpdate: 'CASCADE', + onDelete: 'CASCADE', + }); // Many-to-many associations (cross-references) db.Membership.withManyToMany( diff --git a/libs/model/src/models/groupPermission.ts b/libs/model/src/models/groupPermission.ts index d75f23d68d8..932e89313a2 100644 --- a/libs/model/src/models/groupPermission.ts +++ b/libs/model/src/models/groupPermission.ts @@ -2,11 +2,13 @@ import { GroupPermission } from '@hicommonwealth/schemas'; import Sequelize from 'sequelize'; // must use "* as" to avoid scope errors import { z } from 'zod'; import { GroupAttributes } from './group'; +import { TopicAttributes } from './topic'; import type { ModelInstance } from './types'; export type GroupPermissionAttributes = z.infer & { // associations Group?: GroupAttributes; + Topic?: TopicAttributes; }; export type GroupPermissionInstance = ModelInstance; @@ -22,6 +24,11 @@ export default ( allowNull: false, primaryKey: true, }, + topic_id: { + type: Sequelize.INTEGER, + allowNull: false, + primaryKey: true, + }, allowed_actions: { // This needs to be a string[] because enum[] will break sequelize.sync and fail tests type: Sequelize.ARRAY(Sequelize.STRING), diff --git a/libs/model/src/thread/CreateThread.command.ts b/libs/model/src/thread/CreateThread.command.ts index 579350acae6..e7faf3af990 100644 --- a/libs/model/src/thread/CreateThread.command.ts +++ b/libs/model/src/thread/CreateThread.command.ts @@ -88,7 +88,9 @@ export function CreateThread(): Command< return { ...schemas.CreateThread, auth: [ - isAuthorized({ action: schemas.PermissionEnum.CREATE_THREAD }), + isAuthorized({ + action: schemas.PermissionEnum.CREATE_THREAD + }), verifyThreadSignature, ], body: async ({ actor, payload, auth }) => { diff --git a/libs/model/test/community/community-lifecycle.spec.ts b/libs/model/test/community/community-lifecycle.spec.ts index 9adb0f9f583..0e16c5f3c6f 100644 --- a/libs/model/test/community/community-lifecycle.spec.ts +++ b/libs/model/test/community/community-lifecycle.spec.ts @@ -7,7 +7,10 @@ import { dispose, query, } from '@hicommonwealth/core'; -import { TopicWeightedVoting } from '@hicommonwealth/schemas'; +import { + PermissionEnum, + TopicWeightedVoting, +} from '@hicommonwealth/schemas'; import { ChainBase, ChainType } from '@hicommonwealth/shared'; import { Chance } from 'chance'; import { CreateTopic } from 'model/src/community/CreateTopic.command'; @@ -38,7 +41,10 @@ import { seed } from '../../src/tester'; const chance = Chance(); -function buildCreateGroupPayload(community_id: string, topics: number[] = []) { +function buildCreateGroupPayload( + community_id: string, + topics: { id: number; permissions: PermissionEnum[] }[] = [], +) { return { community_id, metadata: { @@ -355,7 +361,20 @@ describe('Community lifecycle', () => { await expect( command(CreateGroup(), { actor: ethAdminActor, - payload: buildCreateGroupPayload(community.id, [1, 2, 3]), + payload: buildCreateGroupPayload(community.id, [ + { + id: 1, + permissions: [PermissionEnum.CREATE_COMMENT, PermissionEnum.CREATE_THREAD, PermissionEnum.CREATE_COMMENT_REACTION,PermissionEnum.CREATE_THREAD_REACTION], + }, + { + id: 2, + permissions: [PermissionEnum.CREATE_COMMENT, PermissionEnum.CREATE_THREAD, PermissionEnum.CREATE_COMMENT_REACTION,PermissionEnum.CREATE_THREAD_REACTION], + }, + { + id: 3, + permissions: [PermissionEnum.CREATE_COMMENT, PermissionEnum.CREATE_THREAD, PermissionEnum.CREATE_COMMENT_REACTION,PermissionEnum.CREATE_THREAD_REACTION], + }, + ]), }), ).rejects.toThrow(CreateGroupErrors.InvalidTopics); }); diff --git a/libs/model/test/thread/thread-lifecycle.spec.ts b/libs/model/test/thread/thread-lifecycle.spec.ts index 866dc4c2ff7..8efbe52a47a 100644 --- a/libs/model/test/thread/thread-lifecycle.spec.ts +++ b/libs/model/test/thread/thread-lifecycle.spec.ts @@ -163,6 +163,7 @@ describe('Thread lifecycle', () => { }); await seed('GroupPermission', { group_id: threadGroupId, + topic_id: _community?.topics?.[0]?.id || 0, allowed_actions: [ schemas.PermissionEnum.CREATE_THREAD, schemas.PermissionEnum.CREATE_THREAD_REACTION, @@ -171,6 +172,7 @@ describe('Thread lifecycle', () => { }); await seed('GroupPermission', { group_id: commentGroupId, + topic_id: _community?.topics?.[0]?.id || 0, allowed_actions: [schemas.PermissionEnum.CREATE_COMMENT], }); diff --git a/libs/schemas/src/commands/community.schemas.ts b/libs/schemas/src/commands/community.schemas.ts index 05953b63b97..22d9a5df459 100644 --- a/libs/schemas/src/commands/community.schemas.ts +++ b/libs/schemas/src/commands/community.schemas.ts @@ -12,6 +12,7 @@ import { z } from 'zod'; import { Community, Group, + PermissionEnum, Requirement, StakeTransaction, Topic, @@ -228,7 +229,14 @@ export const CreateGroup = { community_id: z.string(), metadata: GroupMetadata, requirements: z.array(Requirement).optional(), - topics: z.array(PG_INT).optional(), + topics: z + .array( + z.object({ + id: PG_INT, + permissions: z.array(z.nativeEnum(PermissionEnum)), + }), + ) + .optional(), }), output: Community.extend({ groups: z.array(Group).optional() }).partial(), }; @@ -239,7 +247,14 @@ export const UpdateGroup = { group_id: PG_INT, metadata: GroupMetadata.optional(), requirements: z.array(Requirement).optional(), - topics: z.array(PG_INT).optional(), + topics: z + .array( + z.object({ + id: PG_INT, + permissions: z.array(z.nativeEnum(PermissionEnum)), + }), + ) + .optional(), }), output: Group.partial(), }; diff --git a/libs/schemas/src/entities/group-permission.schemas.ts b/libs/schemas/src/entities/group-permission.schemas.ts index b003e01cc65..c466c5d489e 100644 --- a/libs/schemas/src/entities/group-permission.schemas.ts +++ b/libs/schemas/src/entities/group-permission.schemas.ts @@ -6,15 +6,15 @@ export enum PermissionEnum { CREATE_COMMENT = 'CREATE_COMMENT', CREATE_THREAD_REACTION = 'CREATE_THREAD_REACTION', CREATE_COMMENT_REACTION = 'CREATE_COMMENT_REACTION', - UPDATE_POLL = 'UPDATE_POLL', + UPDATE_POLL = 'UPDATE_POLL' } export type GroupPermissionAction = keyof typeof PermissionEnum; export const GroupPermission = z.object({ - group_id: PG_INT.optional(), + group_id: PG_INT, + topic_id: PG_INT, allowed_actions: z.array(z.nativeEnum(PermissionEnum)), - created_at: z.coerce.date().optional(), updated_at: z.coerce.date().optional(), }); diff --git a/packages/commonwealth/client/scripts/helpers/threads.ts b/packages/commonwealth/client/scripts/helpers/threads.ts index c12c00c6014..3a6bf52c48b 100644 --- a/packages/commonwealth/client/scripts/helpers/threads.ts +++ b/packages/commonwealth/client/scripts/helpers/threads.ts @@ -1,5 +1,8 @@ +import { PermissionEnum } from '@hicommonwealth/schemas'; import { re_weburl } from 'lib/url-validation'; import { Link, LinkSource } from 'models/Thread'; +// eslint-disable-next-line max-len +import { convertGranularPermissionsToAccumulatedPermissions } from '../views/pages/CommunityGroupsAndMembers/Groups/common/GroupForm/helpers'; export function detectURL(str: string) { if (str.slice(0, 4) !== 'http') str = `http://${str}`; // no https required because this is only used for regex match @@ -55,11 +58,13 @@ export const getThreadActionTooltipText = ({ isThreadArchived = false, isThreadLocked = false, isThreadTopicGated = false, + threadTopicInteractionRestrictions, }: { isCommunityMember?: boolean; isThreadArchived?: boolean; isThreadLocked?: boolean; isThreadTopicGated?: boolean; + threadTopicInteractionRestrictions?: PermissionEnum[]; }): GetThreadActionTooltipTextResponse => { if (!isCommunityMember) { return getActionTooltipForNonCommunityMember; @@ -67,5 +72,10 @@ export const getThreadActionTooltipText = ({ if (isThreadArchived) return 'Thread is archived'; if (isThreadLocked) return 'Thread is locked'; if (isThreadTopicGated) return 'Topic is gated'; + if (threadTopicInteractionRestrictions) { + return `Topic members are only allowed to ${convertGranularPermissionsToAccumulatedPermissions( + threadTopicInteractionRestrictions, + )}`; + } return ''; }; diff --git a/packages/commonwealth/client/scripts/hooks/useTopicGating.ts b/packages/commonwealth/client/scripts/hooks/useTopicGating.ts new file mode 100644 index 00000000000..5eb80c682c1 --- /dev/null +++ b/packages/commonwealth/client/scripts/hooks/useTopicGating.ts @@ -0,0 +1,76 @@ +import { PermissionEnum } from '@hicommonwealth/schemas'; +import { useRefreshMembershipQuery } from 'state/api/groups'; +import Permissions from '../utils/Permissions'; + +type TopicPermission = { id: number; permissions: PermissionEnum[] }; + +type UseTopicGatingProps = { + communityId: string; + apiEnabled: boolean; + userAddress: string; + topicId?: number; +}; + +const useTopicGating = ({ + apiEnabled, + communityId, + userAddress, + topicId, +}: UseTopicGatingProps) => { + const { data: memberships = [], isLoading: isLoadingMemberships } = + useRefreshMembershipQuery({ + communityId, + address: userAddress, + apiEnabled, + }); + + const topicPermissions = memberships + .map((m) => m.topics) + .flat() + .reduce((acc, current) => { + const existing = acc.find((item) => item.id === current.id); + if (!existing) { + acc.push(current); + // IMP: this logic can break if `PermissionEnum` or the `GroupPermissions` + // schema is changed substantially and might not give off a ts issue. + } else if (current.permissions.length > existing.permissions.length) { + // Replace with the current item if it has a longer permission string + const index = acc.indexOf(existing); + acc[index] = current; + } + return acc; + }, []); + + const isTopicGated = !!(memberships || []).find((membership) => + membership.topics.find((t) => t.id === topicId), + ); + + const isActionAllowedInGatedTopic = !!(memberships || []).find( + (membership) => + membership.topics.find((t) => t.id === topicId) && membership.isAllowed, + ); + + const isAdmin = Permissions.isSiteAdmin() || Permissions.isCommunityAdmin(); + + const isRestrictedMembership = + !isAdmin && isTopicGated && !isActionAllowedInGatedTopic; + + const foundTopicPermissions = topicPermissions.find( + (tp) => tp.id === topicId, + ); + + return { + memberships, + isLoadingMemberships, + topicPermissions, + ...(topicId && { + // only return these fields if `topicId` is present, otherwise these values will be inaccurate + isTopicGated, + isActionAllowedInGatedTopic, + isRestrictedMembership, + foundTopicPermissions, + }), + }; +}; + +export default useTopicGating; diff --git a/packages/commonwealth/client/scripts/state/api/groups/createGroup.ts b/packages/commonwealth/client/scripts/state/api/groups/createGroup.ts index e7ccbfc79bf..fd31b1b774d 100644 --- a/packages/commonwealth/client/scripts/state/api/groups/createGroup.ts +++ b/packages/commonwealth/client/scripts/state/api/groups/createGroup.ts @@ -1,11 +1,12 @@ -import { trpc } from 'client/scripts/utils/trpcClient'; import { ApiEndpoints, queryClient } from 'state/api/config'; +import { trpc } from 'utils/trpcClient'; +import { GroupFormTopicSubmitValues } from 'views/pages/CommunityGroupsAndMembers/Groups/common/GroupForm/index.types'; interface CreateGroupProps { communityId: string; address: string; groupName: string; - topicIds: number[]; + topics: GroupFormTopicSubmitValues[]; groupDescription?: string; requirementsToFulfill: number | undefined; requirements?: any[]; @@ -15,7 +16,7 @@ export const buildCreateGroupInput = ({ communityId, groupName, groupDescription, - topicIds, + topics, requirementsToFulfill, requirements = [], }: CreateGroupProps) => { @@ -31,7 +32,7 @@ export const buildCreateGroupInput = ({ }), }, requirements, - topics: topicIds, + topics, }; }; diff --git a/packages/commonwealth/client/scripts/state/api/groups/editGroup.ts b/packages/commonwealth/client/scripts/state/api/groups/editGroup.ts index 35313e768e2..1e516d6fa9d 100644 --- a/packages/commonwealth/client/scripts/state/api/groups/editGroup.ts +++ b/packages/commonwealth/client/scripts/state/api/groups/editGroup.ts @@ -1,4 +1,5 @@ -import { trpc } from 'client/scripts/utils/trpcClient'; +import { trpc } from 'utils/trpcClient'; +import { GroupFormTopicSubmitValues } from 'views/pages/CommunityGroupsAndMembers/Groups/common/GroupForm/index.types'; import { userStore } from '../../ui/user'; import { ApiEndpoints, queryClient } from '../config'; @@ -8,7 +9,7 @@ interface EditGroupProps { address: string; groupName: string; groupDescription?: string; - topicIds: number[]; + topics: GroupFormTopicSubmitValues[]; requirementsToFulfill: number | undefined; requirements?: any[]; } @@ -19,7 +20,7 @@ export const buildUpdateGroupInput = ({ address, groupName, groupDescription, - topicIds, + topics, requirementsToFulfill, requirements, }: EditGroupProps) => { @@ -37,7 +38,7 @@ export const buildUpdateGroupInput = ({ }), }, ...(requirements && { requirements }), - topics: topicIds, + topics, }; }; diff --git a/packages/commonwealth/client/scripts/state/api/groups/refreshMembership.ts b/packages/commonwealth/client/scripts/state/api/groups/refreshMembership.ts index d35d70ee31f..f7dc9de1498 100644 --- a/packages/commonwealth/client/scripts/state/api/groups/refreshMembership.ts +++ b/packages/commonwealth/client/scripts/state/api/groups/refreshMembership.ts @@ -1,3 +1,4 @@ +import { PermissionEnum } from '@hicommonwealth/schemas'; import { useQuery } from '@tanstack/react-query'; import axios from 'axios'; import { ApiEndpoints, SERVER_URL } from 'state/api/config'; @@ -14,7 +15,7 @@ interface RefreshMembershipProps { export interface Memberships { groupId: number; - topicIds: number[]; + topics: { id: number; permissions: PermissionEnum[] }[]; isAllowed: boolean; rejectReason?: string; } @@ -34,7 +35,7 @@ const refreshMembership = async ({ return response?.data?.result?.map((r) => ({ groupId: r.groupId, - topicIds: r.topicIds, + topics: r.topics, isAllowed: r.allowed, rejectReason: r.rejectReason, })); diff --git a/packages/commonwealth/client/scripts/views/components/NewThreadFormLegacy/NewThreadForm.tsx b/packages/commonwealth/client/scripts/views/components/NewThreadFormLegacy/NewThreadForm.tsx index d13e9513a62..9d525136def 100644 --- a/packages/commonwealth/client/scripts/views/components/NewThreadFormLegacy/NewThreadForm.tsx +++ b/packages/commonwealth/client/scripts/views/components/NewThreadFormLegacy/NewThreadForm.tsx @@ -1,22 +1,22 @@ -import { buildCreateThreadInput } from 'client/scripts/state/api/threads/createThread'; -import { useAuthModalStore } from 'client/scripts/state/ui/modals'; +import { PermissionEnum } from '@hicommonwealth/schemas'; import { notifyError } from 'controllers/app/notifications'; import { SessionKeyError } from 'controllers/server/sessions'; import { parseCustomStages } from 'helpers'; import { detectURL, getThreadActionTooltipText } from 'helpers/threads'; import useJoinCommunityBanner from 'hooks/useJoinCommunityBanner'; +import useTopicGating from 'hooks/useTopicGating'; import { useCommonNavigate } from 'navigation/helpers'; import React, { useEffect, useMemo, useState } from 'react'; import { useLocation } from 'react-router-dom'; import app from 'state'; import { useGetUserEthBalanceQuery } from 'state/api/communityStake'; -import { - useFetchGroupsQuery, - useRefreshMembershipQuery, -} from 'state/api/groups'; +import { useFetchGroupsQuery } from 'state/api/groups'; import { useCreateThreadMutation } from 'state/api/threads'; +import { buildCreateThreadInput } from 'state/api/threads/createThread'; import { useFetchTopicsQuery } from 'state/api/topics'; +import { useAuthModalStore } from 'state/ui/modals'; import useUserStore from 'state/ui/user'; +import Permissions from 'utils/Permissions'; import JoinCommunityBanner from 'views/components/JoinCommunityBanner'; import CustomTopicOption from 'views/components/NewThreadFormLegacy/CustomTopicOption'; import useJoinCommunity from 'views/components/SublayoutHeader/useJoinCommunity'; @@ -28,9 +28,9 @@ import { MessageRow } from 'views/components/component_kit/new_designs/CWTextInp import useCommunityContests from 'views/pages/CommunityManagement/Contests/useCommunityContests'; import useAppStatus from '../../../hooks/useAppStatus'; import { ThreadKind, ThreadStage } from '../../../models/types'; -import Permissions from '../../../utils/Permissions'; import { CWText } from '../../components/component_kit/cw_text'; import { CWGatedTopicBanner } from '../component_kit/CWGatedTopicBanner'; +import { CWGatedTopicPermissionLevelBanner } from '../component_kit/CWGatedTopicPermissionLevelBanner'; import { CWSelectList } from '../component_kit/new_designs/CWSelectList'; import { ReactQuillEditor } from '../react_quill_editor'; import { @@ -64,7 +64,6 @@ export const NewThreadForm = () => { const sortedTopics = [...topics].sort((a, b) => a.name.localeCompare(b.name)); const hasTopics = sortedTopics?.length; - const isAdmin = Permissions.isCommunityAdmin() || Permissions.isSiteAdmin(); const topicsForSelector = hasTopics ? sortedTopics : []; const { @@ -82,6 +81,8 @@ export const NewThreadForm = () => { clearDraft, canShowGatingBanner, setCanShowGatingBanner, + canShowTopicPermissionBanner, + setCanShowTopicPermissionBanner, } = useNewThreadForm(communityId, topicsForSelector); const hasTopicOngoingContest = threadTopic?.activeContestManagers?.length > 0; @@ -108,12 +109,15 @@ export const NewThreadForm = () => { includeTopics: true, enabled: !!communityId, }); - const { data: memberships = [] } = useRefreshMembershipQuery({ + const { isRestrictedMembership, foundTopicPermissions } = useTopicGating({ communityId, - address: user.activeAccount?.address || '', + userAddress: user.activeAccount?.address || '', apiEnabled: !!user.activeAccount?.address && !!communityId, + topicId: threadTopic?.id || 0, }); + const isAdmin = Permissions.isSiteAdmin() || Permissions.isCommunityAdmin(); + const { mutateAsync: createThread } = useCreateThreadMutation({ communityId, }); @@ -137,23 +141,11 @@ export const NewThreadForm = () => { return threadTitle || getTextFromDelta(threadContentDelta).length > 0; }, [threadContentDelta, threadTitle]); - const isTopicGated = !!(memberships || []).find( - (membership) => - threadTopic?.id && membership.topicIds.includes(threadTopic.id), - ); - const isActionAllowedInGatedTopic = !!(memberships || []).find( - (membership) => - threadTopic?.id && - membership.topicIds.includes(threadTopic?.id) && - membership.isAllowed, - ); const gatedGroupNames = groups .filter((group) => group.topics.find((topic) => topic.id === threadTopic?.id), ) .map((group) => group.name); - const isRestrictedMembership = - !isAdmin && isTopicGated && !isActionAllowedInGatedTopic; const handleNewThreadCreation = async () => { if (isRestrictedMembership) { @@ -228,6 +220,13 @@ export const NewThreadForm = () => { const disabledActionsTooltipText = getThreadActionTooltipText({ isCommunityMember: !!user.activeAccount, isThreadTopicGated: isRestrictedMembership, + threadTopicInteractionRestrictions: + !isAdmin && + !foundTopicPermissions?.permissions?.includes( + PermissionEnum.CREATE_THREAD, + ) + ? foundTopicPermissions?.permissions + : undefined, }); const contestThreadBannerVisible = @@ -310,6 +309,7 @@ export const NewThreadForm = () => { } onChange={(topic) => { setCanShowGatingBanner(true); + setCanShowTopicPermissionBanner(true); setThreadTopic( // @ts-expect-error topicsForSelector.find((t) => `${t.id}` === topic.value), @@ -346,7 +346,11 @@ export const NewThreadForm = () => { { !user.activeAccount || isDisabledBecauseOfContestsConsent || walletBalanceError || - contestTopicError + contestTopicError || + !!disabledActionsTooltipText } // eslint-disable-next-line @typescript-eslint/no-misused-promises onClick={handleNewThreadCreation} @@ -411,6 +416,20 @@ export const NewThreadForm = () => { /> )} + + {canShowTopicPermissionBanner && + foundTopicPermissions && + !isAdmin && + !foundTopicPermissions?.permissions?.includes( + PermissionEnum.CREATE_THREAD, + ) && ( + setCanShowTopicPermissionBanner(false)} + /> + )} diff --git a/packages/commonwealth/client/scripts/views/components/NewThreadFormLegacy/helpers/useNewThreadForm.ts b/packages/commonwealth/client/scripts/views/components/NewThreadFormLegacy/helpers/useNewThreadForm.ts index c1784d23d2c..5911f7035b4 100644 --- a/packages/commonwealth/client/scripts/views/components/NewThreadFormLegacy/helpers/useNewThreadForm.ts +++ b/packages/commonwealth/client/scripts/views/components/NewThreadFormLegacy/helpers/useNewThreadForm.ts @@ -21,6 +21,8 @@ const useNewThreadForm = (communityId: string, topicsForSelector: Topic[]) => { `new-thread-${communityId}-info`, ); const [canShowGatingBanner, setCanShowGatingBanner] = useState(true); + const [canShowTopicPermissionBanner, setCanShowTopicPermissionBanner] = + useState(true); // get restored draft on init const restoredDraft: NewThreadDraft | null = useMemo(() => { @@ -116,6 +118,8 @@ const useNewThreadForm = (communityId: string, topicsForSelector: Topic[]) => { clearDraft, canShowGatingBanner, setCanShowGatingBanner, + canShowTopicPermissionBanner, + setCanShowTopicPermissionBanner, }; }; diff --git a/packages/commonwealth/client/scripts/views/components/NewThreadFormModern/NewThreadForm.tsx b/packages/commonwealth/client/scripts/views/components/NewThreadFormModern/NewThreadForm.tsx index a267d27cec0..541a039b5e4 100644 --- a/packages/commonwealth/client/scripts/views/components/NewThreadFormModern/NewThreadForm.tsx +++ b/packages/commonwealth/client/scripts/views/components/NewThreadFormModern/NewThreadForm.tsx @@ -1,22 +1,22 @@ -import { buildCreateThreadInput } from 'client/scripts/state/api/threads/createThread'; -import { useAuthModalStore } from 'client/scripts/state/ui/modals'; +import { PermissionEnum } from '@hicommonwealth/schemas'; import { notifyError } from 'controllers/app/notifications'; import { SessionKeyError } from 'controllers/server/sessions'; import { parseCustomStages } from 'helpers'; import { detectURL, getThreadActionTooltipText } from 'helpers/threads'; import useJoinCommunityBanner from 'hooks/useJoinCommunityBanner'; +import useTopicGating from 'hooks/useTopicGating'; import { useCommonNavigate } from 'navigation/helpers'; import React, { useEffect, useRef, useState } from 'react'; import { useLocation } from 'react-router-dom'; import app from 'state'; import { useGetUserEthBalanceQuery } from 'state/api/communityStake'; -import { - useFetchGroupsQuery, - useRefreshMembershipQuery, -} from 'state/api/groups'; +import { useFetchGroupsQuery } from 'state/api/groups'; import { useCreateThreadMutation } from 'state/api/threads'; +import { buildCreateThreadInput } from 'state/api/threads/createThread'; import { useFetchTopicsQuery } from 'state/api/topics'; +import { useAuthModalStore } from 'state/ui/modals'; import useUserStore from 'state/ui/user'; +import Permissions from 'utils/Permissions'; import JoinCommunityBanner from 'views/components/JoinCommunityBanner'; import MarkdownEditor from 'views/components/MarkdownEditor'; import { MarkdownSubmitButton } from 'views/components/MarkdownEditor/MarkdownSubmitButton'; @@ -30,9 +30,9 @@ import { MessageRow } from 'views/components/component_kit/new_designs/CWTextInp import useCommunityContests from 'views/pages/CommunityManagement/Contests/useCommunityContests'; import useAppStatus from '../../../hooks/useAppStatus'; import { ThreadKind, ThreadStage } from '../../../models/types'; -import Permissions from '../../../utils/Permissions'; import { CWText } from '../../components/component_kit/cw_text'; import { CWGatedTopicBanner } from '../component_kit/CWGatedTopicBanner'; +import { CWGatedTopicPermissionLevelBanner } from '../component_kit/CWGatedTopicPermissionLevelBanner'; import { CWSelectList } from '../component_kit/new_designs/CWSelectList'; import ContestThreadBanner from './ContestThreadBanner'; import ContestTopicBanner from './ContestTopicBanner'; @@ -62,7 +62,6 @@ export const NewThreadForm = () => { const sortedTopics = [...topics].sort((a, b) => a.name.localeCompare(b.name)); const hasTopics = sortedTopics?.length; - const isAdmin = Permissions.isCommunityAdmin() || Permissions.isSiteAdmin(); const topicsForSelector = hasTopics ? sortedTopics : []; const { @@ -79,6 +78,8 @@ export const NewThreadForm = () => { clearDraft, canShowGatingBanner, setCanShowGatingBanner, + canShowTopicPermissionBanner, + setCanShowTopicPermissionBanner, } = useNewThreadForm(communityId, topicsForSelector); const hasTopicOngoingContest = threadTopic?.activeContestManagers?.length > 0; @@ -105,12 +106,15 @@ export const NewThreadForm = () => { includeTopics: true, enabled: !!communityId, }); - const { data: memberships = [] } = useRefreshMembershipQuery({ + const { isRestrictedMembership, foundTopicPermissions } = useTopicGating({ communityId, - address: user.activeAccount?.address || '', + userAddress: user.activeAccount?.address || '', apiEnabled: !!user.activeAccount?.address && !!communityId, + topicId: threadTopic?.id || 0, }); + const isAdmin = Permissions.isSiteAdmin() || Permissions.isCommunityAdmin(); + const { mutateAsync: createThread } = useCreateThreadMutation({ communityId, }); @@ -130,24 +134,11 @@ export const NewThreadForm = () => { const isDiscussion = threadKind === ThreadKind.Discussion; - const isTopicGated = !!(memberships || []).find( - (membership) => - threadTopic?.id && membership.topicIds.includes(threadTopic.id), - ); - const isActionAllowedInGatedTopic = !!(memberships || []).find( - (membership) => - threadTopic.id && - threadTopic?.id && - membership.topicIds.includes(threadTopic?.id) && - membership.isAllowed, - ); const gatedGroupNames = groups .filter((group) => group.topics.find((topic) => topic.id === threadTopic?.id), ) .map((group) => group.name); - const isRestrictedMembership = - !isAdmin && isTopicGated && !isActionAllowedInGatedTopic; const handleNewThreadCreation = async () => { const body = markdownEditorMethodsRef.current!.getMarkdown(); @@ -213,6 +204,13 @@ export const NewThreadForm = () => { const disabledActionsTooltipText = getThreadActionTooltipText({ isCommunityMember: !!user.activeAccount, isThreadTopicGated: isRestrictedMembership, + threadTopicInteractionRestrictions: + !isAdmin && + !foundTopicPermissions?.permissions?.includes( + PermissionEnum.CREATE_THREAD, + ) + ? foundTopicPermissions?.permissions + : undefined, }); const contestThreadBannerVisible = @@ -333,7 +331,11 @@ export const NewThreadForm = () => { (markdownEditorMethodsRef.current = methods) } onChange={(markdown) => setEditorText(markdown)} - disabled={isRestrictedMembership || !user.activeAccount} + disabled={ + isRestrictedMembership || + !!disabledActionsTooltipText || + !user.activeAccount + } tooltip={ typeof disabledActionsTooltipText === 'function' ? disabledActionsTooltipText?.('submit') @@ -346,6 +348,7 @@ export const NewThreadForm = () => { disabled={ isDisabled || !user.activeAccount || + !!disabledActionsTooltipText || isDisabledBecauseOfContestsConsent || walletBalanceError || contestTopicError @@ -387,6 +390,20 @@ export const NewThreadForm = () => { /> )} + + {canShowTopicPermissionBanner && + foundTopicPermissions && + !isAdmin && + !foundTopicPermissions?.permissions?.includes( + PermissionEnum.CREATE_THREAD, + ) && ( + setCanShowTopicPermissionBanner(false)} + /> + )} diff --git a/packages/commonwealth/client/scripts/views/components/NewThreadFormModern/helpers/useNewThreadForm.ts b/packages/commonwealth/client/scripts/views/components/NewThreadFormModern/helpers/useNewThreadForm.ts index 7bef182947f..8931bfd4d10 100644 --- a/packages/commonwealth/client/scripts/views/components/NewThreadFormModern/helpers/useNewThreadForm.ts +++ b/packages/commonwealth/client/scripts/views/components/NewThreadFormModern/helpers/useNewThreadForm.ts @@ -20,6 +20,8 @@ const useNewThreadForm = (communityId: string, topicsForSelector: Topic[]) => { { keyVersion: 'v3' }, ); const [canShowGatingBanner, setCanShowGatingBanner] = useState(true); + const [canShowTopicPermissionBanner, setCanShowTopicPermissionBanner] = + useState(true); // get restored draft on init const restoredDraft: NewThreadDraft | null = useMemo(() => { @@ -113,6 +115,8 @@ const useNewThreadForm = (communityId: string, topicsForSelector: Topic[]) => { clearDraft, canShowGatingBanner, setCanShowGatingBanner, + canShowTopicPermissionBanner, + setCanShowTopicPermissionBanner, }; }; diff --git a/packages/commonwealth/client/scripts/views/components/component_kit/CWContentPage/CWContentPage.tsx b/packages/commonwealth/client/scripts/views/components/component_kit/CWContentPage/CWContentPage.tsx index ea04b676a9e..d0af21acb68 100644 --- a/packages/commonwealth/client/scripts/views/components/component_kit/CWContentPage/CWContentPage.tsx +++ b/packages/commonwealth/client/scripts/views/components/component_kit/CWContentPage/CWContentPage.tsx @@ -1,12 +1,13 @@ +import { PermissionEnum } from '@hicommonwealth/schemas'; import { getThreadActionTooltipText } from 'helpers/threads'; import { truncate } from 'helpers/truncate'; +import useTopicGating from 'hooks/useTopicGating'; import { IThreadCollaborator } from 'models/Thread'; import moment from 'moment'; import React, { ReactNode, useMemo, useState } from 'react'; import { useNavigate } from 'react-router'; import { useSearchParams } from 'react-router-dom'; import app from 'state'; -import { useRefreshMembershipQuery } from 'state/api/groups'; import useUserStore from 'state/ui/user'; import Permissions from 'utils/Permissions'; import { ThreadContestTagContainer } from 'views/components/ThreadContestTag'; @@ -125,26 +126,15 @@ export const CWContentPage = ({ const [isUpvoteDrawerOpen, setIsUpvoteDrawerOpen] = useState(false); const communityId = app.activeChainId() || ''; - const { data: memberships = [] } = useRefreshMembershipQuery({ + + const { isRestrictedMembership, foundTopicPermissions } = useTopicGating({ communityId, - address: user.activeAccount?.address || '', + userAddress: user.activeAccount?.address || '', apiEnabled: !!user.activeAccount?.address && !!communityId, + topicId: thread?.topic?.id || 0, }); - const isTopicGated = !!(memberships || []).find((membership) => - // @ts-expect-error - membership.topicIds.includes(thread?.topic?.id), - ); - - const isActionAllowedInGatedTopic = !!(memberships || []).find( - (membership) => - // @ts-expect-error - membership.topicIds.includes(thread?.topic?.id) && membership.isAllowed, - ); - const isAdmin = Permissions.isSiteAdmin() || Permissions.isCommunityAdmin(); - const isRestrictedMembership = - !isAdmin && isTopicGated && !isActionAllowedInGatedTopic; const tabSelected = useMemo(() => { const tab = Object.fromEntries(urlQueryParams.entries())?.tab; @@ -223,6 +213,31 @@ export const CWContentPage = ({ isThreadTopicGated: isRestrictedMembership, }); + const disabledReactPermissionTooltipText = getThreadActionTooltipText({ + isCommunityMember: !!user.activeAccount, + threadTopicInteractionRestrictions: + !isAdmin && + !foundTopicPermissions?.permissions?.includes( + PermissionEnum.CREATE_COMMENT_REACTION, + ) && + !foundTopicPermissions?.permissions?.includes( + PermissionEnum.CREATE_THREAD_REACTION, + ) + ? foundTopicPermissions?.permissions + : undefined, + }); + + const disabledCommentPermissionTooltipText = getThreadActionTooltipText({ + isCommunityMember: !!user.activeAccount, + threadTopicInteractionRestrictions: + !isAdmin && + !foundTopicPermissions?.permissions?.includes( + PermissionEnum.CREATE_COMMENT, + ) + ? foundTopicPermissions?.permissions + : undefined, + }); + const mainBody = (
@@ -259,10 +274,22 @@ export const CWContentPage = ({ onEditStart={onEditStart} canUpdateThread={canUpdateThread} hasPendingEdits={hasPendingEdits} - canReact={!disabledActionsTooltipText} - canComment={!disabledActionsTooltipText} + canReact={ + disabledReactPermissionTooltipText + ? !disabledReactPermissionTooltipText + : !disabledActionsTooltipText + } + canComment={ + disabledCommentPermissionTooltipText + ? !disabledCommentPermissionTooltipText + : !disabledActionsTooltipText + } onProposalStageChange={onProposalStageChange} - disabledActionsTooltipText={disabledActionsTooltipText} + disabledActionsTooltipText={ + disabledReactPermissionTooltipText || + disabledCommentPermissionTooltipText || + disabledActionsTooltipText + } onSnapshotProposalFromThread={onSnapshotProposalFromThread} setIsUpvoteDrawerOpen={setIsUpvoteDrawerOpen} shareEndpoint={`${window.location.origin}${window.location.pathname}`} diff --git a/packages/commonwealth/client/scripts/views/components/component_kit/CWGatedTopicPermissionLevelBanner/CWGatedTopicPermissionLevelBanner.tsx b/packages/commonwealth/client/scripts/views/components/component_kit/CWGatedTopicPermissionLevelBanner/CWGatedTopicPermissionLevelBanner.tsx new file mode 100644 index 00000000000..15c4e6f2031 --- /dev/null +++ b/packages/commonwealth/client/scripts/views/components/component_kit/CWGatedTopicPermissionLevelBanner/CWGatedTopicPermissionLevelBanner.tsx @@ -0,0 +1,61 @@ +import { PermissionEnum } from '@hicommonwealth/schemas'; +import { useBrowserAnalyticsTrack } from 'hooks/useBrowserAnalyticsTrack'; +import { useCommonNavigate } from 'navigation/helpers'; +import React from 'react'; +// eslint-disable-next-line max-len +import { convertGranularPermissionsToAccumulatedPermissions } from 'views/pages/CommunityGroupsAndMembers/Groups/common/GroupForm/helpers'; +import { + MixpanelClickthroughEvent, + MixpanelClickthroughPayload, +} from '../../../../../../shared/analytics/types'; +import useAppStatus from '../../../../hooks/useAppStatus'; +import CWBanner from '../new_designs/CWBanner'; + +interface CWGatedTopicPermissionLevelBannerProps { + onClose: () => void; + topicPermissions: PermissionEnum[]; +} + +const CWGatedTopicPermissionLevelBanner = ({ + onClose = () => {}, + topicPermissions, +}: CWGatedTopicPermissionLevelBannerProps) => { + const navigate = useCommonNavigate(); + + const { isAddedToHomeScreen } = useAppStatus(); + + const { trackAnalytics } = + useBrowserAnalyticsTrack({ + onAction: true, + }); + + return ( + { + trackAnalytics({ + event: MixpanelClickthroughEvent.VIEW_THREAD_TO_MEMBERS_PAGE, + isPWA: isAddedToHomeScreen, + }); + navigate('/members?tab=groups'); + }, + }, + { + label: 'Learn more about gating', + onClick: () => + window.open( + `https://blog.commonwealth.im/introducing-common-groups/`, + ), + }, + ]} + onClose={onClose} + /> + ); +}; + +export default CWGatedTopicPermissionLevelBanner; diff --git a/packages/commonwealth/client/scripts/views/components/component_kit/CWGatedTopicPermissionLevelBanner/index.ts b/packages/commonwealth/client/scripts/views/components/component_kit/CWGatedTopicPermissionLevelBanner/index.ts new file mode 100644 index 00000000000..d3b7f6d1cbb --- /dev/null +++ b/packages/commonwealth/client/scripts/views/components/component_kit/CWGatedTopicPermissionLevelBanner/index.ts @@ -0,0 +1,3 @@ +import CWGatedTopicPermissionLevelBanner from './CWGatedTopicPermissionLevelBanner'; + +export { CWGatedTopicPermissionLevelBanner }; diff --git a/packages/commonwealth/client/scripts/views/components/feed.tsx b/packages/commonwealth/client/scripts/views/components/feed.tsx index 3784fe063c3..ec8b76e428b 100644 --- a/packages/commonwealth/client/scripts/views/components/feed.tsx +++ b/packages/commonwealth/client/scripts/views/components/feed.tsx @@ -3,28 +3,33 @@ import { Virtuoso } from 'react-virtuoso'; import 'components/feed.scss'; -import { ActivityComment, ActivityThread } from '@hicommonwealth/schemas'; -import { slugify } from '@hicommonwealth/shared'; -import { Thread, type RecentComment } from 'client/scripts/models/Thread'; -import Topic from 'client/scripts/models/Topic'; -import { ThreadKind, ThreadStage } from 'client/scripts/models/types'; +import { PageNotFound } from '../pages/404'; +import { UserDashboardRowSkeleton } from '../pages/user_dashboard/user_dashboard_row'; + import { - useFetchGlobalActivityQuery, - useFetchUserActivityQuery, -} from 'client/scripts/state/api/feeds/fetchUserActivity'; + ActivityComment, + ActivityThread, + PermissionEnum, +} from '@hicommonwealth/schemas'; +import { slugify } from '@hicommonwealth/shared'; import { getThreadActionTooltipText } from 'helpers/threads'; +import useTopicGating from 'hooks/useTopicGating'; import { getProposalUrlPath } from 'identifiers'; +import { Thread, type RecentComment } from 'models/Thread'; +import Topic from 'models/Topic'; +import { ThreadKind, ThreadStage } from 'models/types'; import { useCommonNavigate } from 'navigation/helpers'; import { useGetCommunityByIdQuery } from 'state/api/communities'; import { useFetchCustomDomainQuery } from 'state/api/configuration'; -import { useRefreshMembershipQuery } from 'state/api/groups'; +import { + useFetchGlobalActivityQuery, + useFetchUserActivityQuery, +} from 'state/api/feeds/fetchUserActivity'; import useUserStore from 'state/ui/user'; import Permissions from 'utils/Permissions'; import { DashboardViews } from 'views/pages/user_dashboard'; import { z } from 'zod'; -import { PageNotFound } from '../pages/404'; import { ThreadCard } from '../pages/discussions/ThreadCard'; -import { UserDashboardRowSkeleton } from '../pages/user_dashboard/user_dashboard_row'; type FeedProps = { dashboardView: DashboardViews; @@ -53,34 +58,23 @@ const FeedThread = ({ thread }: { thread: Thread }) => { enabled: !!thread.communityId, }); - const isAdmin = - Permissions.isSiteAdmin() || Permissions.isCommunityAdmin(community); - const account = user.addresses?.find( (a) => a?.community?.id === thread?.communityId, ); - const { data: memberships = [] } = useRefreshMembershipQuery({ + const { isRestrictedMembership, foundTopicPermissions } = useTopicGating({ communityId: thread.communityId, - // @ts-expect-error - address: account?.address, + userAddress: account?.address || '', apiEnabled: !!account?.address && !!thread.communityId, + topicId: thread?.topic?.id || 0, }); - const isTopicGated = !!(memberships || []).find( - (membership) => - thread?.topic?.id && membership.topicIds.includes(thread.topic.id), - ); - - const isActionAllowedInGatedTopic = !!(memberships || []).find( - (membership) => - thread?.topic?.id && - membership.topicIds.includes(thread.topic.id) && - membership.isAllowed, - ); - - const isRestrictedMembership = - !isAdmin && isTopicGated && !isActionAllowedInGatedTopic; + const isAdmin = + Permissions.isSiteAdmin() || + Permissions.isCommunityAdmin({ + id: community?.id || '', + adminsAndMods: community?.adminsAndMods || [], + }); const disabledActionsTooltipText = getThreadActionTooltipText({ isCommunityMember: Permissions.isCommunityMember(thread.communityId), @@ -89,6 +83,17 @@ const FeedThread = ({ thread }: { thread: Thread }) => { isThreadTopicGated: isRestrictedMembership, }); + const disabledCommentActionTooltipText = getThreadActionTooltipText({ + isCommunityMember: Permissions.isCommunityMember(thread.communityId), + threadTopicInteractionRestrictions: + !isAdmin && + !foundTopicPermissions?.permissions?.includes( + PermissionEnum.CREATE_COMMENT, // on this page we only show comment option + ) + ? foundTopicPermissions?.permissions + : undefined, + }); + // edge case for deleted communities with orphaned posts if (!community) { return ( @@ -100,7 +105,7 @@ const FeedThread = ({ thread }: { thread: Thread }) => { { navigate( @@ -111,7 +116,11 @@ const FeedThread = ({ thread }: { thread: Thread }) => { }} threadHref={discussionLink} onCommentBtnClick={() => navigate(`${discussionLink}?focusComments=true`)} - disabledActionsTooltipText={disabledActionsTooltipText} + disabledActionsTooltipText={ + disabledCommentActionTooltipText + ? disabledCommentActionTooltipText + : disabledActionsTooltipText + } customStages={community.custom_stages} hideReactionButton hideUpvotesDrawer diff --git a/packages/commonwealth/client/scripts/views/pages/CommunityGroupsAndMembers/Groups/Update/UpdateCommunityGroupPage.tsx b/packages/commonwealth/client/scripts/views/pages/CommunityGroupsAndMembers/Groups/Update/UpdateCommunityGroupPage.tsx index 6ce8cd64232..680e5c6375b 100644 --- a/packages/commonwealth/client/scripts/views/pages/CommunityGroupsAndMembers/Groups/Update/UpdateCommunityGroupPage.tsx +++ b/packages/commonwealth/client/scripts/views/pages/CommunityGroupsAndMembers/Groups/Update/UpdateCommunityGroupPage.tsx @@ -21,6 +21,7 @@ import { import { convertRequirementAmountFromWeiToTokens } from '../../common/helpers'; import { DeleteGroupModal } from '../DeleteGroupModal'; import { GroupForm } from '../common/GroupForm'; +import { convertGranularPermissionsToAccumulatedPermissions } from '../common/GroupForm/helpers'; import { makeGroupDataBaseAPIPayload } from '../common/helpers'; import './UpdateCommunityGroupPage.scss'; @@ -131,6 +132,9 @@ const UpdateCommunityGroupPage = ({ groupId }: { groupId: string }) => { topics: (foundGroup.topics || []).map((topic) => ({ label: topic.name, value: topic.id, + permission: convertGranularPermissionsToAccumulatedPermissions( + topic.permissions || [], + ), })), }} onSubmit={(values) => { diff --git a/packages/commonwealth/client/scripts/views/pages/CommunityGroupsAndMembers/Groups/common/GroupForm/Allowlist/Allowlist.tsx b/packages/commonwealth/client/scripts/views/pages/CommunityGroupsAndMembers/Groups/common/GroupForm/Allowlist/Allowlist.tsx index 555cb7af480..2c90236c6e9 100644 --- a/packages/commonwealth/client/scripts/views/pages/CommunityGroupsAndMembers/Groups/common/GroupForm/Allowlist/Allowlist.tsx +++ b/packages/commonwealth/client/scripts/views/pages/CommunityGroupsAndMembers/Groups/common/GroupForm/Allowlist/Allowlist.tsx @@ -2,10 +2,10 @@ import { DEFAULT_NAME } from '@hicommonwealth/shared'; import { MagnifyingGlass } from '@phosphor-icons/react'; import { formatAddressShort } from 'helpers'; import { APIOrderDirection } from 'helpers/constants'; +import useTopicGating from 'hooks/useTopicGating'; import React, { useMemo, useState } from 'react'; import app from 'state'; import { useGetCommunityByIdQuery } from 'state/api/communities'; -import { useRefreshMembershipQuery } from 'state/api/groups'; import useUserStore from 'state/ui/user'; import { useDebounce } from 'usehooks-ts'; import { OptionConfig, Select } from 'views/components/Select'; @@ -85,9 +85,9 @@ const Allowlist = ({ const [currentPage, setCurrentPage] = useState(1); const communityId = app.activeChainId() || ''; - const { data: memberships } = useRefreshMembershipQuery({ + const { memberships } = useTopicGating({ communityId, - address: user.activeAccount?.address || '', + userAddress: user.activeAccount?.address || '', apiEnabled: !!user.activeAccount?.address, }); diff --git a/packages/commonwealth/client/scripts/views/pages/CommunityGroupsAndMembers/Groups/common/GroupForm/GroupForm.scss b/packages/commonwealth/client/scripts/views/pages/CommunityGroupsAndMembers/Groups/common/GroupForm/GroupForm.scss index 92a7056367e..94dd4e13a16 100644 --- a/packages/commonwealth/client/scripts/views/pages/CommunityGroupsAndMembers/Groups/common/GroupForm/GroupForm.scss +++ b/packages/commonwealth/client/scripts/views/pages/CommunityGroupsAndMembers/Groups/common/GroupForm/GroupForm.scss @@ -43,6 +43,18 @@ color: $neutral-500; } } + + .topic-permission-header { + padding: 0 12px; + + @include extraSmall { + padding: 0 8px; + } + } + + .divider-spacing { + margin: -10px 0px; + } } .action-buttons { diff --git a/packages/commonwealth/client/scripts/views/pages/CommunityGroupsAndMembers/Groups/common/GroupForm/GroupForm.tsx b/packages/commonwealth/client/scripts/views/pages/CommunityGroupsAndMembers/Groups/common/GroupForm/GroupForm.tsx index 747e49526c8..0c52bb76d9d 100644 --- a/packages/commonwealth/client/scripts/views/pages/CommunityGroupsAndMembers/Groups/common/GroupForm/GroupForm.tsx +++ b/packages/commonwealth/client/scripts/views/pages/CommunityGroupsAndMembers/Groups/common/GroupForm/GroupForm.tsx @@ -25,11 +25,20 @@ import { import Allowlist from './Allowlist'; import './GroupForm.scss'; import RequirementSubForm from './RequirementSubForm'; +import TopicPermissionsSubForm from './TopicPermissionsSubForm'; +import { + REQUIREMENTS_TO_FULFILL, + REVERSED_TOPIC_PERMISSIONS, + TOPIC_PERMISSIONS, +} from './constants'; +import { convertAccumulatedPermissionsToGranularPermissions } from './helpers'; import { FormSubmitValues, GroupFormProps, RequirementSubFormsState, RequirementSubType, + TopicPermissions, + TopicPermissionsSubFormsState, } from './index.types'; import { VALIDATION_MESSAGES, @@ -37,11 +46,6 @@ import { requirementSubFormValidationSchema, } from './validations'; -const REQUIREMENTS_TO_FULFILL = { - ALL_REQUIREMENTS: 'ALL', - N_REQUIREMENTS: 'N', -}; - type CWRequirementsRadioButtonProps = { maxRequirements: number; inputValue: string; @@ -170,6 +174,9 @@ const GroupForm = ({ const [requirementSubForms, setRequirementSubForms] = useState< RequirementSubFormsState[] >([]); + const [topicPermissionsSubForms, setTopicPermissionsSubForms] = useState< + TopicPermissionsSubFormsState[] + >([]); useEffect(() => { if (initialValues.requirements) { @@ -203,6 +210,15 @@ const GroupForm = ({ `${initialValues.requirementsToFulfill}`, ); } + + if (initialValues.topics) { + setTopicPermissionsSubForms( + initialValues.topics.map((t) => ({ + permission: t.permission, + topic: { id: parseInt(`${t.value}`), name: t.label }, + })), + ); + } // eslint-disable-next-line react-hooks/exhaustive-deps }, []); @@ -371,6 +387,12 @@ const GroupForm = ({ const formValues = { ...values, + topics: topicPermissionsSubForms.map((t) => ({ + id: t.topic.id, + permissions: convertAccumulatedPermissionsToGranularPermissions( + REVERSED_TOPIC_PERMISSIONS[t.permission], + ), + })), requirementsToFulfill, requirements: requirementSubForms.map((x) => x.values), }; @@ -378,6 +400,31 @@ const GroupForm = ({ await onSubmit(formValues); }; + const handleWatchForm = (values: FormSubmitValues) => { + if (values?.topics?.length > 0) { + setTopicPermissionsSubForms( + values.topics.map((topic) => ({ + topic: { + id: parseInt(`${topic.value}`), + name: topic.label, + }, + permission: TOPIC_PERMISSIONS.UPVOTE_AND_COMMENT_AND_POST, + })), + ); + } else { + setTopicPermissionsSubForms([]); + } + }; + + const updateTopicPermissionByIndex = ( + index: number, + newPermission: TopicPermissions, + ) => { + const updatedTopicPermissionsSubForms = [...topicPermissionsSubForms]; + updatedTopicPermissionsSubForms[index].permission = newPermission; + setTopicPermissionsSubForms([...updatedTopicPermissionsSubForms]); + }; + // + 1 for allowlists const maxRequirements = requirementSubForms.length + 1; @@ -394,11 +441,16 @@ const GroupForm = ({ ? REQUIREMENTS_TO_FULFILL.ALL_REQUIREMENTS : REQUIREMENTS_TO_FULFILL.N_REQUIREMENTS : '', - topics: initialValues.topics || '', + topics: + initialValues?.topics?.map((t) => ({ + label: t.label, + value: t.value, + })) || '', }} validationSchema={groupValidationSchema} onSubmit={handleSubmit} onErrors={validateSubForms} + onWatch={handleWatchForm} > {({ formState }) => ( <> @@ -567,6 +619,46 @@ const GroupForm = ({ }))} /> + + {/* Sub-section: Gated topic permissions */} + {topicPermissionsSubForms?.length > 0 && ( +
+
+ + Topic Permissions + + + Select which topics this group can create threads and + within. + +
+ + + Topic + + + {topicPermissionsSubForms.map((topicPermission, index) => ( + <> + + + updateTopicPermissionByIndex(index, newPermission) + } + /> + {index === topicPermissionsSubForms.length - 1 && ( + + )} + + ))} +
+ )} ({ + label: permission, + value: permission, +})); + +const TopicPermissionsSubForm = ({ + topic, + defaultPermission, + onPermissionChange, +}: TopicPermissionsSubFormType) => { + return ( +
+ {topic.name} + + p.value === defaultPermission)} + options={permissionMap} + onChange={(option) => option?.value && onPermissionChange(option.value)} + /> +
+ ); +}; + +export default TopicPermissionsSubForm; diff --git a/packages/commonwealth/client/scripts/views/pages/CommunityGroupsAndMembers/Groups/common/GroupForm/TopicPermissionsSubForm/index.tsx b/packages/commonwealth/client/scripts/views/pages/CommunityGroupsAndMembers/Groups/common/GroupForm/TopicPermissionsSubForm/index.tsx new file mode 100644 index 00000000000..15331b9c7ff --- /dev/null +++ b/packages/commonwealth/client/scripts/views/pages/CommunityGroupsAndMembers/Groups/common/GroupForm/TopicPermissionsSubForm/index.tsx @@ -0,0 +1 @@ +export { default } from './TopicPermissionsSubForm'; diff --git a/packages/commonwealth/client/scripts/views/pages/CommunityGroupsAndMembers/Groups/common/GroupForm/constants.ts b/packages/commonwealth/client/scripts/views/pages/CommunityGroupsAndMembers/Groups/common/GroupForm/constants.ts new file mode 100644 index 00000000000..d87892352d3 --- /dev/null +++ b/packages/commonwealth/client/scripts/views/pages/CommunityGroupsAndMembers/Groups/common/GroupForm/constants.ts @@ -0,0 +1,25 @@ +import { GroupTopicPermissionEnum } from './index.types'; + +export const REQUIREMENTS_TO_FULFILL = { + ALL_REQUIREMENTS: 'ALL', + N_REQUIREMENTS: 'N', +}; + +export const TOPIC_PERMISSIONS = { + [GroupTopicPermissionEnum.UPVOTE]: 'Upvote', + [GroupTopicPermissionEnum.UPVOTE_AND_COMMENT]: 'Upvote & Comment', + [GroupTopicPermissionEnum.UPVOTE_AND_COMMENT_AND_POST]: + 'Upvote & Comment & Post', +}; + +export type TopicPermissions = + (typeof TOPIC_PERMISSIONS)[keyof typeof TOPIC_PERMISSIONS]; + +type ReversedTopicPermissions = { + [K in keyof typeof TOPIC_PERMISSIONS as (typeof TOPIC_PERMISSIONS)[K]]: K; +}; + +export const REVERSED_TOPIC_PERMISSIONS: ReversedTopicPermissions = + Object.fromEntries( + Object.entries(TOPIC_PERMISSIONS).map(([key, value]) => [value, key]), + ) as ReversedTopicPermissions; diff --git a/packages/commonwealth/client/scripts/views/pages/CommunityGroupsAndMembers/Groups/common/GroupForm/helpers.ts b/packages/commonwealth/client/scripts/views/pages/CommunityGroupsAndMembers/Groups/common/GroupForm/helpers.ts new file mode 100644 index 00000000000..f537029a80d --- /dev/null +++ b/packages/commonwealth/client/scripts/views/pages/CommunityGroupsAndMembers/Groups/common/GroupForm/helpers.ts @@ -0,0 +1,59 @@ +import { PermissionEnum } from '@hicommonwealth/schemas'; +import { TOPIC_PERMISSIONS, TopicPermissions } from './constants'; +import { GroupTopicPermissionEnum } from './index.types'; + +export const convertAccumulatedPermissionsToGranularPermissions = ( + permission: GroupTopicPermissionEnum, +): PermissionEnum[] => { + const basePermissions = [ + PermissionEnum.CREATE_COMMENT_REACTION, + PermissionEnum.CREATE_THREAD_REACTION, + ]; + + switch (permission) { + case GroupTopicPermissionEnum.UPVOTE: + return basePermissions; + case GroupTopicPermissionEnum.UPVOTE_AND_COMMENT: + return [...basePermissions, PermissionEnum.CREATE_COMMENT]; + case GroupTopicPermissionEnum.UPVOTE_AND_COMMENT_AND_POST: + return [ + ...basePermissions, + PermissionEnum.CREATE_COMMENT, + PermissionEnum.CREATE_THREAD, + ]; + default: + return []; + } +}; + +export const convertGranularPermissionsToAccumulatedPermissions = ( + permissions: PermissionEnum[], +): TopicPermissions => { + if ( + permissions.includes(PermissionEnum.CREATE_COMMENT) && + permissions.includes(PermissionEnum.CREATE_COMMENT_REACTION) && + permissions.includes(PermissionEnum.CREATE_THREAD_REACTION) && + permissions.includes(PermissionEnum.CREATE_THREAD) + ) { + return TOPIC_PERMISSIONS[ + GroupTopicPermissionEnum.UPVOTE_AND_COMMENT_AND_POST + ]; + } + + if ( + permissions.includes(PermissionEnum.CREATE_COMMENT) && + permissions.includes(PermissionEnum.CREATE_COMMENT_REACTION) && + permissions.includes(PermissionEnum.CREATE_THREAD_REACTION) + ) { + return TOPIC_PERMISSIONS[GroupTopicPermissionEnum.UPVOTE_AND_COMMENT]; + } + + if ( + permissions.includes(PermissionEnum.CREATE_COMMENT_REACTION) && + permissions.includes(PermissionEnum.CREATE_THREAD_REACTION) + ) { + return TOPIC_PERMISSIONS[GroupTopicPermissionEnum.UPVOTE]; + } + + return TOPIC_PERMISSIONS.UPVOTE_AND_COMMENT_AND_POST; +}; diff --git a/packages/commonwealth/client/scripts/views/pages/CommunityGroupsAndMembers/Groups/common/GroupForm/index.types.ts b/packages/commonwealth/client/scripts/views/pages/CommunityGroupsAndMembers/Groups/common/GroupForm/index.types.ts index e24198d10e8..21b5783bed9 100644 --- a/packages/commonwealth/client/scripts/views/pages/CommunityGroupsAndMembers/Groups/common/GroupForm/index.types.ts +++ b/packages/commonwealth/client/scripts/views/pages/CommunityGroupsAndMembers/Groups/common/GroupForm/index.types.ts @@ -1,3 +1,12 @@ +import { PermissionEnum } from '@hicommonwealth/schemas'; +import { TOPIC_PERMISSIONS } from './constants'; + +export enum GroupTopicPermissionEnum { + UPVOTE = 'UPVOTE', + UPVOTE_AND_COMMENT = 'UPVOTE_AND_COMMENT', + UPVOTE_AND_COMMENT_AND_POST = 'UPVOTE_AND_COMMENT_AND_POST', +} + export type RequirementSubFormsState = { defaultValues?: RequirementSubTypeWithLabel; values: RequirementSubType; @@ -13,6 +22,20 @@ export type RequirementSubType = { requirementTokenId?: string; }; +export type TopicPermissions = + (typeof TOPIC_PERMISSIONS)[keyof typeof TOPIC_PERMISSIONS]; + +export type TopicPermissionsSubFormsState = { + topic: TopicPermissionsSubFormType['topic']; + permission: TopicPermissions; +}; + +export type TopicPermissionsSubFormType = { + topic: { id: number; name: string }; + defaultPermission?: TopicPermissions; + onPermissionChange: (permission: string) => void; +}; + export type LabelType = { label: string; value: string; @@ -35,12 +58,17 @@ export type RequirementSubFormType = { onChange: (values: RequirementSubType) => any; }; +export type GroupFormTopicSubmitValues = { + id: number; + permissions: PermissionEnum[]; +}; + export type GroupResponseValuesType = { groupName: string; groupDescription?: string; requirementsToFulfill: 'ALL' | number; requirements?: RequirementSubType[]; - topics: LabelType[]; + topics: GroupFormTopicSubmitValues[]; allowlist?: number[]; }; @@ -49,7 +77,7 @@ export type GroupInitialValuesTypeWithLabel = { groupDescription?: string; requirements?: RequirementSubTypeWithLabel[]; requirementsToFulfill?: 'ALL' | number; - topics: LabelType[]; + topics: (LabelType & { permission: TopicPermissions })[]; }; export type FormSubmitValues = { diff --git a/packages/commonwealth/client/scripts/views/pages/CommunityGroupsAndMembers/Groups/common/helpers/index.ts b/packages/commonwealth/client/scripts/views/pages/CommunityGroupsAndMembers/Groups/common/helpers/index.ts index 10618e2771d..c4618a28369 100644 --- a/packages/commonwealth/client/scripts/views/pages/CommunityGroupsAndMembers/Groups/common/helpers/index.ts +++ b/packages/commonwealth/client/scripts/views/pages/CommunityGroupsAndMembers/Groups/common/helpers/index.ts @@ -22,7 +22,7 @@ export const makeGroupDataBaseAPIPayload = ( address: userStore.getState().activeAccount?.address || '', groupName: formSubmitValues.groupName.trim(), groupDescription: (formSubmitValues.groupDescription || '').trim(), - topicIds: formSubmitValues.topics.map((x) => parseInt(x.value)), + topics: formSubmitValues.topics, requirementsToFulfill: formSubmitValues.requirementsToFulfill === 'ALL' ? // @ts-expect-error StrictNullChecks diff --git a/packages/commonwealth/client/scripts/views/pages/CommunityGroupsAndMembers/Members/CommunityMembersPage.tsx b/packages/commonwealth/client/scripts/views/pages/CommunityGroupsAndMembers/Members/CommunityMembersPage.tsx index 12fc7ea6fdf..549a88a7d5a 100644 --- a/packages/commonwealth/client/scripts/views/pages/CommunityGroupsAndMembers/Members/CommunityMembersPage.tsx +++ b/packages/commonwealth/client/scripts/views/pages/CommunityGroupsAndMembers/Members/CommunityMembersPage.tsx @@ -1,6 +1,7 @@ import { DEFAULT_NAME } from '@hicommonwealth/shared'; import { APIOrderDirection } from 'helpers/constants'; import { useBrowserAnalyticsTrack } from 'hooks/useBrowserAnalyticsTrack'; +import useTopicGating from 'hooks/useTopicGating'; import moment from 'moment'; import { useCommonNavigate } from 'navigation/helpers'; import React, { useEffect, useMemo, useState } from 'react'; @@ -12,10 +13,7 @@ import { import app from 'state'; import { useGetCommunityByIdQuery } from 'state/api/communities'; import { ApiEndpoints, queryClient } from 'state/api/config'; -import { - useFetchGroupsQuery, - useRefreshMembershipQuery, -} from 'state/api/groups'; +import { useFetchGroupsQuery } from 'state/api/groups'; import { SearchProfilesResponse } from 'state/api/profiles/searchProfiles'; import useGroupMutationBannerStore from 'state/ui/group'; import useUserStore from 'state/ui/user'; @@ -81,9 +79,9 @@ const CommunityMembersPage = () => { }); const communityId = app.activeChainId() || ''; - const { data: memberships = null } = useRefreshMembershipQuery({ + const { memberships } = useTopicGating({ communityId, - address: user?.activeAccount?.address || '', + userAddress: user?.activeAccount?.address || '', apiEnabled: !!user?.activeAccount?.address && !!communityId, }); diff --git a/packages/commonwealth/client/scripts/views/pages/CommunityGroupsAndMembers/Members/GroupsSection/GroupCard/GroupCard.scss b/packages/commonwealth/client/scripts/views/pages/CommunityGroupsAndMembers/Members/GroupsSection/GroupCard/GroupCard.scss index bc01ce00a87..aaa2dce4216 100644 --- a/packages/commonwealth/client/scripts/views/pages/CommunityGroupsAndMembers/Members/GroupsSection/GroupCard/GroupCard.scss +++ b/packages/commonwealth/client/scripts/views/pages/CommunityGroupsAndMembers/Members/GroupsSection/GroupCard/GroupCard.scss @@ -35,18 +35,41 @@ color: $neutral-500; font-size: 18px; } + .caption { color: $neutral-500; } } } - .gating-tags { + .gating-topics { display: flex; - flex-wrap: wrap; + flex-direction: column; gap: 8px; - align-items: center; width: 100%; + + .row { + padding: 8px 0; + width: 100%; + display: grid; + align-items: center; + grid-template-columns: 1fr 1fr; + + .Tag { + height: auto; + + .Text { + height: fit-content; + } + + @include extraSmall { + .Text { + word-wrap: break-word; + white-space: normal; + } + } + } + } } .allowlist-table { diff --git a/packages/commonwealth/client/scripts/views/pages/CommunityGroupsAndMembers/Members/GroupsSection/GroupCard/GroupCard.tsx b/packages/commonwealth/client/scripts/views/pages/CommunityGroupsAndMembers/Members/GroupsSection/GroupCard/GroupCard.tsx index 7d3d878c732..17067a7461d 100644 --- a/packages/commonwealth/client/scripts/views/pages/CommunityGroupsAndMembers/Members/GroupsSection/GroupCard/GroupCard.tsx +++ b/packages/commonwealth/client/scripts/views/pages/CommunityGroupsAndMembers/Members/GroupsSection/GroupCard/GroupCard.tsx @@ -1,5 +1,4 @@ import useBrowserWindow from 'hooks/useBrowserWindow'; -import MinimumProfile from 'models/MinimumProfile'; import React, { useState } from 'react'; import { Link } from 'react-router-dom'; import { Avatar } from 'views/components/Avatar'; @@ -9,32 +8,13 @@ import { CWText } from 'views/components/component_kit/cw_text'; import { CWTag } from 'views/components/component_kit/new_designs/CWTag'; import { formatAddressShort } from '../../../../../../helpers'; import CWPagination from '../../../../../components/component_kit/new_designs/CWPagination/CWPagination'; +import { convertGranularPermissionsToAccumulatedPermissions } from '../../../Groups/common/GroupForm/helpers'; import './GroupCard.scss'; import RequirementCard from './RequirementCard/RequirementCard'; - -type RequirementCardProps = { - requirementType: string; - requirementChain: string; - requirementContractAddress?: string; - requirementCondition: string; - requirementAmount: string; - requirementTokenId?: string; -}; - -type GroupCardProps = { - isJoined?: boolean; - groupName: string; - groupDescription?: string; - requirements?: RequirementCardProps[]; // This represents erc requirements - requirementsToFulfill: 'ALL' | number; - allowLists?: string[]; - topics: { id: number; name: string }[]; - canEdit?: boolean; - onEditClick?: () => any; - profiles?: Map; -}; +import { GroupCardProps } from './types'; const ALLOWLIST_MEMBERS_PER_PAGE = 7; + const GroupCard = ({ isJoined, groupName, @@ -163,9 +143,26 @@ const GroupCard = ({ {topics.length > 0 && ( <> Gated Topics -
+
+
+ Topic + Permission +
{topics.map((t, index) => ( - +
+ +
+ {t.name} + + +
+ +
))}
diff --git a/packages/commonwealth/client/scripts/views/pages/CommunityGroupsAndMembers/Members/GroupsSection/GroupCard/types.ts b/packages/commonwealth/client/scripts/views/pages/CommunityGroupsAndMembers/Members/GroupsSection/GroupCard/types.ts new file mode 100644 index 00000000000..3444c2477b1 --- /dev/null +++ b/packages/commonwealth/client/scripts/views/pages/CommunityGroupsAndMembers/Members/GroupsSection/GroupCard/types.ts @@ -0,0 +1,24 @@ +import { PermissionEnum } from '@hicommonwealth/schemas'; +import MinimumProfile from 'models/MinimumProfile'; + +export type RequirementCardProps = { + requirementType: string; + requirementChain: string; + requirementContractAddress?: string; + requirementCondition: string; + requirementAmount: string; + requirementTokenId?: string; +}; + +export type GroupCardProps = { + isJoined?: boolean; + groupName: string; + groupDescription?: string; + requirements?: RequirementCardProps[]; // This represents erc requirements + requirementsToFulfill: 'ALL' | number; + allowLists?: string[]; + topics: { id: number; name: string; permissions?: PermissionEnum[] }[]; + canEdit?: boolean; + onEditClick?: () => void; + profiles?: Map; +}; diff --git a/packages/commonwealth/client/scripts/views/pages/CommunityGroupsAndMembers/Members/GroupsSection/GroupsSection.tsx b/packages/commonwealth/client/scripts/views/pages/CommunityGroupsAndMembers/Members/GroupsSection/GroupsSection.tsx index efebb54f3ea..77f0ffc1612 100644 --- a/packages/commonwealth/client/scripts/views/pages/CommunityGroupsAndMembers/Members/GroupsSection/GroupsSection.tsx +++ b/packages/commonwealth/client/scripts/views/pages/CommunityGroupsAndMembers/Members/GroupsSection/GroupsSection.tsx @@ -92,6 +92,7 @@ const GroupsSection = ({ topics={(group?.topics || []).map((x) => ({ id: x.id, name: x.name, + permissions: x.permissions, }))} canEdit={canManageGroups} onEditClick={() => navigate(`/members/groups/${group.id}/update`)} diff --git a/packages/commonwealth/client/scripts/views/pages/discussions/CommentTree/CommentTree.tsx b/packages/commonwealth/client/scripts/views/pages/discussions/CommentTree/CommentTree.tsx index 35c2a4abe34..296cedcecf3 100644 --- a/packages/commonwealth/client/scripts/views/pages/discussions/CommentTree/CommentTree.tsx +++ b/packages/commonwealth/client/scripts/views/pages/discussions/CommentTree/CommentTree.tsx @@ -530,6 +530,11 @@ export const CommentTree = ({ parentCommentId={parentCommentId} rootThread={thread} canComment={canComment} + tooltipText={ + !canComment && typeof disabledActionsTooltipText === 'string' + ? disabledActionsTooltipText + : '' + } /> )} diff --git a/packages/commonwealth/client/scripts/views/pages/discussions/DiscussionsPage.tsx b/packages/commonwealth/client/scripts/views/pages/discussions/DiscussionsPage.tsx index 6c015fc3c43..ba8cdf0211c 100644 --- a/packages/commonwealth/client/scripts/views/pages/discussions/DiscussionsPage.tsx +++ b/packages/commonwealth/client/scripts/views/pages/discussions/DiscussionsPage.tsx @@ -1,4 +1,4 @@ -import { TopicWeightedVoting } from '@hicommonwealth/schemas'; +import { PermissionEnum, TopicWeightedVoting } from '@hicommonwealth/schemas'; import { getProposalUrlPath } from 'identifiers'; import { getScopePrefix, useCommonNavigate } from 'navigation/helpers'; import React, { useEffect, useRef, useState } from 'react'; @@ -24,10 +24,10 @@ import { getThreadActionTooltipText } from 'helpers/threads'; import useBrowserWindow from 'hooks/useBrowserWindow'; import { useFlag } from 'hooks/useFlag'; import useManageDocumentTitle from 'hooks/useManageDocumentTitle'; +import useTopicGating from 'hooks/useTopicGating'; import 'pages/discussions/index.scss'; import { useGetCommunityByIdQuery } from 'state/api/communities'; import { useFetchCustomDomainQuery } from 'state/api/configuration'; -import { useRefreshMembershipQuery } from 'state/api/groups'; import useUserStore from 'state/ui/user'; import Permissions from 'utils/Permissions'; import { checkIsTopicInContest } from 'views/components/NewThreadFormLegacy/helpers'; @@ -89,9 +89,9 @@ const DiscussionsPage = ({ topicName }: DiscussionsPageProps) => { const user = useUserStore(); - const { data: memberships = [] } = useRefreshMembershipQuery({ + const { memberships, topicPermissions } = useTopicGating({ communityId: communityId, - address: user.activeAccount?.address || '', + userAddress: user.activeAccount?.address || '', apiEnabled: !!user.activeAccount?.address && !!communityId, }); @@ -207,19 +207,23 @@ const DiscussionsPage = ({ topicName }: DiscussionsPageProps) => { const isTopicGated = !!(memberships || []).find( (membership) => thread?.topic?.id && - membership.topicIds.includes(thread.topic.id), + membership.topics.find((t) => t.id === thread.topic.id), ); const isActionAllowedInGatedTopic = !!(memberships || []).find( (membership) => thread?.topic?.id && - membership.topicIds.includes(thread.topic.id) && + membership.topics.find((t) => t.id === thread.topic.id) && membership.isAllowed, ); const isRestrictedMembership = !isAdmin && isTopicGated && !isActionAllowedInGatedTopic; + const foundTopicPermissions = topicPermissions.find( + (tp) => tp.id === thread.topic.id, + ); + const disabledActionsTooltipText = getThreadActionTooltipText({ isCommunityMember: !!user.activeAccount, isThreadArchived: !!thread?.archivedAt, @@ -227,6 +231,32 @@ const DiscussionsPage = ({ topicName }: DiscussionsPageProps) => { isThreadTopicGated: isRestrictedMembership, }); + const disabledReactPermissionTooltipText = getThreadActionTooltipText( + { + isCommunityMember: !!user.activeAccount, + threadTopicInteractionRestrictions: + !isAdmin && + !foundTopicPermissions?.permissions?.includes( + // this should be updated if we start displaying recent comments on this page + PermissionEnum.CREATE_THREAD_REACTION, + ) + ? foundTopicPermissions?.permissions + : undefined, + }, + ); + + const disabledCommentPermissionTooltipText = + getThreadActionTooltipText({ + isCommunityMember: !!user.activeAccount, + threadTopicInteractionRestrictions: + !isAdmin && + !foundTopicPermissions?.permissions?.includes( + PermissionEnum.CREATE_COMMENT, + ) + ? foundTopicPermissions?.permissions + : undefined, + }); + const isThreadTopicInContest = checkIsTopicInContest( contestsData, thread?.topic?.id, @@ -236,8 +266,16 @@ const DiscussionsPage = ({ topicName }: DiscussionsPageProps) => { navigate(`${discussionLink}`)} onStageTagClick={() => { navigate(`/discussions?stage=${thread.stage}`); @@ -252,7 +290,11 @@ const DiscussionsPage = ({ topicName }: DiscussionsPageProps) => { onCommentBtnClick={() => navigate(`${discussionLink}?focusComments=true`) } - disabledActionsTooltipText={disabledActionsTooltipText} + disabledActionsTooltipText={ + disabledCommentPermissionTooltipText || + disabledReactPermissionTooltipText || + disabledActionsTooltipText + } hideRecentComments editingDisabled={isThreadTopicInContest} /> diff --git a/packages/commonwealth/client/scripts/views/pages/overview/TopicSummaryRow.tsx b/packages/commonwealth/client/scripts/views/pages/overview/TopicSummaryRow.tsx index bc402155416..574abdb5fd4 100644 --- a/packages/commonwealth/client/scripts/views/pages/overview/TopicSummaryRow.tsx +++ b/packages/commonwealth/client/scripts/views/pages/overview/TopicSummaryRow.tsx @@ -1,11 +1,12 @@ +import { PermissionEnum } from '@hicommonwealth/schemas'; import { slugify } from '@hicommonwealth/shared'; import { getThreadActionTooltipText } from 'helpers/threads'; +import useTopicGating from 'hooks/useTopicGating'; import { getProposalUrlPath } from 'identifiers'; import { useCommonNavigate } from 'navigation/helpers'; import 'pages/overview/TopicSummaryRow.scss'; import React from 'react'; import app from 'state'; -import { useRefreshMembershipQuery } from 'state/api/groups'; import useUserStore from 'state/ui/user'; import Permissions from 'utils/Permissions'; import type Thread from '../../../models/Thread'; @@ -31,9 +32,10 @@ export const TopicSummaryRow = ({ const user = useUserStore(); const communityId = app.activeChainId() || ''; - const { data: memberships = [] } = useRefreshMembershipQuery({ + + const { memberships, topicPermissions } = useTopicGating({ communityId, - address: user.activeAccount?.address || '', + userAddress: user.activeAccount?.address || '', apiEnabled: !!user.activeAccount?.address || !!communityId, }); @@ -92,19 +94,23 @@ export const TopicSummaryRow = ({ const isTopicGated = !!(memberships || []).find( (membership) => thread?.topic?.id && - membership.topicIds.includes(thread.topic.id), + membership.topics.find((t) => t.id === thread.topic.id), ); const isActionAllowedInGatedTopic = !!(memberships || []).find( (membership) => thread?.topic?.id && - membership.topicIds.includes(thread.topic.id) && + membership.topics.find((t) => t.id === thread.topic.id) && membership.isAllowed, ); const isRestrictedMembership = !isAdmin && isTopicGated && !isActionAllowedInGatedTopic; + const foundTopicPermissions = topicPermissions.find( + (tp) => tp.id === thread.topic.id, + ); + const disabledActionsTooltipText = getThreadActionTooltipText({ isCommunityMember: Permissions.isCommunityMember( thread.communityId, @@ -112,6 +118,13 @@ export const TopicSummaryRow = ({ isThreadArchived: !!thread?.archivedAt, isThreadLocked: !!thread?.lockedAt, isThreadTopicGated: isRestrictedMembership, + threadTopicInteractionRestrictions: + !isAdmin && + !foundTopicPermissions?.permissions?.includes( + PermissionEnum.CREATE_COMMENT, // on this page we only show comment option + ) + ? foundTopicPermissions?.permissions + : undefined, }); return ( diff --git a/packages/commonwealth/client/scripts/views/pages/view_thread/ViewThreadPage.tsx b/packages/commonwealth/client/scripts/views/pages/view_thread/ViewThreadPage.tsx index 807e5f859ac..2dc1cd8caba 100644 --- a/packages/commonwealth/client/scripts/views/pages/view_thread/ViewThreadPage.tsx +++ b/packages/commonwealth/client/scripts/views/pages/view_thread/ViewThreadPage.tsx @@ -1,3 +1,4 @@ +import { PermissionEnum } from '@hicommonwealth/schemas'; import { ContentType, getThreadUrl } from '@hicommonwealth/shared'; import { notifyError } from 'controllers/app/notifications'; import { extractDomain, isDefaultStage } from 'helpers'; @@ -6,6 +7,7 @@ import { filterLinks, getThreadActionTooltipText } from 'helpers/threads'; import { useBrowserAnalyticsTrack } from 'hooks/useBrowserAnalyticsTrack'; import useBrowserWindow from 'hooks/useBrowserWindow'; import useJoinCommunityBanner from 'hooks/useJoinCommunityBanner'; +import useTopicGating from 'hooks/useTopicGating'; import moment from 'moment'; import { useCommonNavigate } from 'navigation/helpers'; import 'pages/view_thread/index.scss'; @@ -14,10 +16,7 @@ import { Helmet } from 'react-helmet-async'; import app from 'state'; import { useFetchCommentsQuery } from 'state/api/comments'; import useGetViewCountByObjectIdQuery from 'state/api/general/getViewCountByObjectId'; -import { - useFetchGroupsQuery, - useRefreshMembershipQuery, -} from 'state/api/groups'; +import { useFetchGroupsQuery } from 'state/api/groups'; import { useAddThreadLinksMutation, useGetThreadPollsQuery, @@ -142,10 +141,11 @@ const ViewThreadPage = ({ identifier }: ViewThreadPageProps) => { threadId: parseInt(threadId), }); - const { data: memberships = [] } = useRefreshMembershipQuery({ + const { isRestrictedMembership, foundTopicPermissions } = useTopicGating({ communityId, - address: user?.activeAccount?.address || '', apiEnabled: !!user?.activeAccount?.address && !!communityId, + userAddress: user?.activeAccount?.address || '', + topicId: thread?.topic?.id || 0, }); const { data: viewCount = 0 } = useGetViewCountByObjectIdQuery({ @@ -154,20 +154,6 @@ const ViewThreadPage = ({ identifier }: ViewThreadPageProps) => { apiCallEnabled: !!thread?.id && !!communityId, }); - const isTopicGated = !!(memberships || []).find((membership) => - // @ts-expect-error - membership.topicIds.includes(thread?.topic?.id), - ); - - const isActionAllowedInGatedTopic = !!(memberships || []).find( - (membership) => - // @ts-expect-error - membership.topicIds.includes(thread?.topic?.id) && membership.isAllowed, - ); - - const isRestrictedMembership = - !isAdmin && isTopicGated && !isActionAllowedInGatedTopic; - useEffect(() => { if (fetchCommentsError) notifyError('Failed to load comments'); }, [fetchCommentsError]); @@ -271,8 +257,6 @@ const ViewThreadPage = ({ identifier }: ViewThreadPageProps) => { // @ts-expect-error const hasWebLinks = thread.links.find((x) => x.source === 'web'); - const canComment = !!user.activeAccount && !isRestrictedMembership; - const handleNewSnapshotChange = async ({ id, snapshot_title, @@ -347,8 +331,20 @@ const ViewThreadPage = ({ identifier }: ViewThreadPageProps) => { isThreadArchived: !!thread?.archivedAt, isThreadLocked: !!thread?.lockedAt, isThreadTopicGated: isRestrictedMembership, + threadTopicInteractionRestrictions: + !isAdmin && + !foundTopicPermissions?.permissions?.includes( + PermissionEnum.CREATE_COMMENT, + ) + ? foundTopicPermissions?.permissions + : undefined, }); + const canComment = + !!user.activeAccount && + !isRestrictedMembership && + !disabledActionsTooltipText; + const getMetaDescription = (meta: string) => { try { const parsedMeta = JSON.parse(meta); diff --git a/packages/commonwealth/server/controllers/server_groups_methods/get_groups.ts b/packages/commonwealth/server/controllers/server_groups_methods/get_groups.ts index aa7fb05078f..7bb193623fa 100644 --- a/packages/commonwealth/server/controllers/server_groups_methods/get_groups.ts +++ b/packages/commonwealth/server/controllers/server_groups_methods/get_groups.ts @@ -1,8 +1,10 @@ import { GroupAttributes, + GroupInstance, MembershipAttributes, TopicAttributes, } from '@hicommonwealth/model'; +import { PermissionEnum } from '@hicommonwealth/schemas'; import { Op, WhereOptions } from 'sequelize'; import { ServerGroupsController } from '../server_groups_controller'; @@ -12,12 +14,23 @@ export type GetGroupsOptions = { includeTopics?: boolean; }; +export type TopicAttributesWithPermission = TopicAttributes & { + permissions: PermissionEnum[]; +}; + type GroupWithExtras = GroupAttributes & { memberships?: MembershipAttributes[]; - topics?: TopicAttributes[]; + topics?: TopicAttributesWithPermission[]; }; export type GetGroupsResult = GroupWithExtras[]; +export type GroupInstanceWithTopicPermissions = GroupInstance & { + GroupPermissions: { + topic_id: number; + allowed_actions: PermissionEnum[]; + }[]; +}; + export async function __getGroups( this: ServerGroupsController, { communityId, includeMembers, includeTopics }: GetGroupsOptions, @@ -26,6 +39,12 @@ export async function __getGroups( where: { community_id: communityId, }, + include: [ + { + model: this.models.GroupPermission, + attributes: ['topic_id', 'allowed_actions'], + }, + ], }); let groupsResult = groups.map((group) => group.toJSON() as GroupWithExtras); @@ -68,11 +87,19 @@ export async function __getGroups( }, }, }); + groupsResult = groupsResult.map((group) => ({ ...group, topics: topics - .map((t) => t.toJSON()) - .filter((t) => t.group_ids!.includes(group.id!)), + .filter((t) => t.group_ids!.includes(group.id!)) + .map((t) => { + const temp: TopicAttributesWithPermission = { ...t.toJSON() }; + temp.permissions = ( + (group as GroupInstanceWithTopicPermissions).GroupPermissions || [] + ).find((gtp) => gtp.topic_id === t.id) + ?.allowed_actions as PermissionEnum[]; + return temp; + }), })); } diff --git a/packages/commonwealth/server/controllers/server_groups_methods/refresh_membership.ts b/packages/commonwealth/server/controllers/server_groups_methods/refresh_membership.ts index c59552b43c6..948b28e74ec 100644 --- a/packages/commonwealth/server/controllers/server_groups_methods/refresh_membership.ts +++ b/packages/commonwealth/server/controllers/server_groups_methods/refresh_membership.ts @@ -4,9 +4,11 @@ import { MembershipRejectReason, UserInstance, } from '@hicommonwealth/model'; +import { PermissionEnum } from '@hicommonwealth/schemas'; import { Op } from 'sequelize'; import { refreshMembershipsForAddress } from '../../util/requirementsModule/refreshMembershipsForAddress'; import { ServerGroupsController } from '../server_groups_controller'; +import { GroupInstanceWithTopicPermissions } from './get_groups'; const Errors = { TopicNotFound: 'Topic not found', @@ -32,6 +34,12 @@ export async function __refreshMembership( where: { community_id: address.community_id, }, + include: [ + { + model: this.models.GroupPermission, + attributes: ['topic_id', 'allowed_actions'], + }, + ], }); // optionally filter to only groups associated with topic @@ -63,9 +71,15 @@ export async function __refreshMembership( // transform memberships to result shape const results = memberships.map((membership) => ({ groupId: membership.group_id, - topicIds: topics + topics: topics .filter((t) => t.group_ids!.includes(membership.group_id)) - .map((t) => t.id), + .map((t) => ({ + id: t.id, + permissions: (groups as GroupInstanceWithTopicPermissions[]) + .find((g) => g.id === membership.group_id) + ?.GroupPermissions?.find((gtp) => gtp.topic_id === t.id) + ?.allowed_actions as PermissionEnum[], + })), allowed: !membership.reject_reason, rejectReason: membership.reject_reason, })); diff --git a/packages/commonwealth/server/migrations/20241015145424-add-topic_id-for-each-group_id-in-GroupPermissions.js b/packages/commonwealth/server/migrations/20241015145424-add-topic_id-for-each-group_id-in-GroupPermissions.js new file mode 100644 index 00000000000..84591ed65a0 --- /dev/null +++ b/packages/commonwealth/server/migrations/20241015145424-add-topic_id-for-each-group_id-in-GroupPermissions.js @@ -0,0 +1,75 @@ +'use strict'; + +module.exports = { + up: async (queryInterface, Sequelize) => { + return queryInterface.sequelize.transaction(async (t) => { + await queryInterface.removeConstraint( + 'GroupPermissions', + 'GroupPermissions_pkey', + { transaction: t }, + ); + + await queryInterface.removeConstraint( + 'GroupPermissions', + 'GroupPermissions_group_id_fkey', + { transaction: t }, + ); + + await queryInterface.addColumn( + 'GroupPermissions', + 'topic_id', + { + type: Sequelize.INTEGER, + allowNull: false, + primaryKey: true, + references: { model: 'Topics', key: 'id' }, + onUpdate: 'CASCADE', + onDelete: 'CASCADE', + }, + { transaction: t }, + ); + + await queryInterface.changeColumn( + 'GroupPermissions', + 'group_id', + { + type: Sequelize.INTEGER, + allowNull: false, + primaryKey: true, + references: { model: 'Groups', key: 'id' }, + onUpdate: 'CASCADE', + onDelete: 'CASCADE', + }, + { transaction: t }, + ); + + await queryInterface.addConstraint('GroupPermissions', { + type: 'primary key', + fields: ['group_id', 'topic_id'], + name: 'GroupPermissions_pkey', + transaction: t, + }); + }); + }, + + down: async (queryInterface, Sequelize) => { + await queryInterface.sequelize.transaction(async (t) => { + await queryInterface.removeColumn('GroupPermissions', 'topic_id', { + transaction: t, + }); + await queryInterface.changeColumn( + 'GroupPermissions', + 'group_id', + { + type: Sequelize.INTEGER, + allowNull: true, + primaryKey: true, + references: { model: 'Groups', key: 'id' }, + onUpdate: 'CASCADE', + onDelete: 'CASCADE', + }, + { transaction: t }, + ); + }); + }, +}; diff --git a/packages/commonwealth/server/migrations/20241015181202-migrate-existing-group-topic-permissions.js b/packages/commonwealth/server/migrations/20241015181202-migrate-existing-group-topic-permissions.js new file mode 100644 index 00000000000..2835f567d55 --- /dev/null +++ b/packages/commonwealth/server/migrations/20241015181202-migrate-existing-group-topic-permissions.js @@ -0,0 +1,53 @@ +'use strict'; +// TODO - remove this +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.sequelize.transaction(async (t) => { + await queryInterface.sequelize.query( + ` + DO $$ + BEGIN IF NOT EXISTS ( + SELECT 1 + FROM pg_type + WHERE typname = 'enum_GroupPermissions_allowed_actions' + ) + THEN + CREATE TYPE enum_GroupPermissions_allowed_actions AS ENUM ( + 'CREATE_COMMENT_REACTION', + 'CREATE_THREAD_REACTION', + 'CREATE_COMMENT', + 'CREATE_THREAD' + ); + END IF; + END $$; + + + INSERT INTO "GroupPermissions" + (group_id, topic_id, allowed_actions, created_at, updated_at) + SELECT + unnest(t.group_ids) AS group_id, + t.id AS topic_id, + ARRAY[ + 'CREATE_COMMENT_REACTION', + 'CREATE_THREAD_REACTION', + 'CREATE_COMMENT', + 'CREATE_THREAD' + ]::"enum_GroupPermissions_allowed_actions"[] + AS allowed_actions, + NOW() AS created_at, + NOW() AS updated_at + FROM "Topics" t + WHERE + ARRAY_LENGTH(t.group_ids, 1) IS NOT NULL + AND ARRAY_LENGTH(t.group_ids, 1) > 0; + `, + { transaction: t }, + ); + }); + }, + + async down(queryInterface, Sequelize) { + // not really-possible/recommended to rollback since "GroupPermissions" might have more entries + }, +};