diff --git a/libs/model/src/community/CreateTopic.command.ts b/libs/model/src/community/CreateTopic.command.ts index 6e8299cd1e2..35f7aaf4cd4 100644 --- a/libs/model/src/community/CreateTopic.command.ts +++ b/libs/model/src/community/CreateTopic.command.ts @@ -57,7 +57,7 @@ export function CreateTopic(): Command< throw new InvalidState(Errors.StakeNotAllowed); } - if (config.CONTESTS.FLAG_FARCASTER_CONTEST) { + if (config.CONTESTS.FLAG_WEIGHTED_TOPICS) { // new path: stake or ERC20 if (payload.weighted_voting) { options = { diff --git a/libs/model/src/config.ts b/libs/model/src/config.ts index 9bf8492c982..3c5c07cbedd 100644 --- a/libs/model/src/config.ts +++ b/libs/model/src/config.ts @@ -30,7 +30,7 @@ const { ETH_RPC, COSMOS_REGISTRY_API, REACTION_WEIGHT_OVERRIDE, - FLAG_FARCASTER_CONTEST, + FLAG_WEIGHTED_TOPICS, ALCHEMY_PRIVATE_APP_KEY, ALCHEMY_PUBLIC_APP_KEY, MEMBERSHIP_REFRESH_BATCH_SIZE, @@ -85,7 +85,7 @@ export const config = configure( MAX_USER_POSTS_PER_CONTEST: MAX_USER_POSTS_PER_CONTEST ? parseInt(MAX_USER_POSTS_PER_CONTEST, 10) : 2, - FLAG_FARCASTER_CONTEST: FLAG_FARCASTER_CONTEST === 'true', + FLAG_WEIGHTED_TOPICS: FLAG_WEIGHTED_TOPICS === 'true', }, AUTH: { JWT_SECRET: JWT_SECRET || DEFAULTS.JWT_SECRET, @@ -183,7 +183,7 @@ export const config = configure( CONTESTS: z.object({ MIN_USER_ETH: z.number(), MAX_USER_POSTS_PER_CONTEST: z.number().int(), - FLAG_FARCASTER_CONTEST: z.boolean(), + FLAG_WEIGHTED_TOPICS: z.boolean(), }), AUTH: z .object({ diff --git a/packages/commonwealth/client/scripts/helpers/ContractHelpers/Abi/NamespaceFactoryAbi.ts b/packages/commonwealth/client/scripts/helpers/ContractHelpers/Abi/NamespaceFactoryAbi.ts index 4314661c8d0..1504b0e381f 100644 --- a/packages/commonwealth/client/scripts/helpers/ContractHelpers/Abi/NamespaceFactoryAbi.ts +++ b/packages/commonwealth/client/scripts/helpers/ContractHelpers/Abi/NamespaceFactoryAbi.ts @@ -1,4 +1,18 @@ export const namespaceFactoryAbi = [ + { + type: 'function', + name: 'newSingleERC20Contest', + inputs: [ + { name: 'name', type: 'string', internalType: 'string' }, + { name: 'length', type: 'uint256', internalType: 'uint256' }, + { name: 'winnerShares', type: 'uint256[]', internalType: 'uint256[]' }, + { name: 'token', type: 'address', internalType: 'address' }, + { name: 'voterShare', type: 'uint256', internalType: 'uint256' }, + { name: 'exhangeToken', type: 'address', internalType: 'address' }, + ], + outputs: [{ name: '', type: 'address', internalType: 'address' }], + stateMutability: 'nonpayable', + }, { inputs: [], stateMutability: 'view', diff --git a/packages/commonwealth/client/scripts/helpers/ContractHelpers/Contest.ts b/packages/commonwealth/client/scripts/helpers/ContractHelpers/Contest.ts index e8dbde5dd8f..b147405b80e 100644 --- a/packages/commonwealth/client/scripts/helpers/ContractHelpers/Contest.ts +++ b/packages/commonwealth/client/scripts/helpers/ContractHelpers/Contest.ts @@ -130,6 +130,43 @@ class Contest extends ContractBase { } } + async newSingleERC20Contest( + namespaceName: string, + contestInterval: number, + winnerShares: number[], + voteToken: string, + voterShare: number, + walletAddress: string, + exchangeToken: string, + ): Promise { + if (!this.initialized || !this.walletEnabled) { + await this.initialize(true); + } + + try { + const txReceipt = await this.namespaceFactory.newERC20Contest( + namespaceName, + contestInterval, + winnerShares, + voteToken, + voterShare, + walletAddress, + exchangeToken, + ); + // @ts-expect-error StrictNullChecks + const eventLog = txReceipt.logs.find((log) => log.topics[0] == TOPIC_LOG); + const newContestAddress = this.web3.eth.abi.decodeParameters( + ['address', 'address', 'uint256', 'bool'], + // @ts-expect-error StrictNullChecks + eventLog.data.toString(), + )['0'] as string; + this.contractAddress = newContestAddress; + return newContestAddress; + } catch (error) { + throw new Error('Failed to initialize contest ' + error); + } + } + /** * Allows for deposit of contest token(ETH or ERC20) to contest * @param amount amount in ether to send to contest diff --git a/packages/commonwealth/client/scripts/helpers/ContractHelpers/NamespaceFactory.ts b/packages/commonwealth/client/scripts/helpers/ContractHelpers/NamespaceFactory.ts index b42006c9949..8b1ae39aa8f 100644 --- a/packages/commonwealth/client/scripts/helpers/ContractHelpers/NamespaceFactory.ts +++ b/packages/commonwealth/client/scripts/helpers/ContractHelpers/NamespaceFactory.ts @@ -218,6 +218,42 @@ class NamespaceFactory extends ContractBase { return txReceipt; } + async newERC20Contest( + namespaceName: string, + contestInterval: number, + winnerShares: number[], + voteToken: string, + voterShare: number, + walletAddress: string, + exchangeToken: string, + ): Promise { + if (!this.initialized || !this.walletEnabled) { + await this.initialize(true); + } + const maxFeePerGasEst = await this.estimateGas(); + let txReceipt; + try { + txReceipt = await this.contract.methods + .newSingleERC20Contest( + namespaceName, + contestInterval, + winnerShares, + voteToken, + voterShare, + exchangeToken, + ) + .send({ + from: walletAddress, + type: '0x2', + maxFeePerGas: maxFeePerGasEst?.toString(), + maxPriorityFeePerGas: this.web3.utils.toWei('0.001', 'gwei'), + }); + } catch { + throw new Error('Transaction failed'); + } + return txReceipt; + } + async getFeeManagerBalance( namespace: string, token?: string, diff --git a/packages/commonwealth/client/scripts/helpers/feature-flags.ts b/packages/commonwealth/client/scripts/helpers/feature-flags.ts index 86abccac51f..0e3d0900634 100644 --- a/packages/commonwealth/client/scripts/helpers/feature-flags.ts +++ b/packages/commonwealth/client/scripts/helpers/feature-flags.ts @@ -23,6 +23,7 @@ const buildFlag = (env: string | undefined) => { const featureFlags = { contest: buildFlag(process.env.FLAG_CONTEST), contestDev: buildFlag(process.env.FLAG_CONTEST_DEV), + weightedTopics: buildFlag(process.env.FLAG_WEIGHTED_TOPICS), knockPushNotifications: buildFlag( process.env.FLAG_KNOCK_PUSH_NOTIFICATIONS_ENABLED, ), diff --git a/packages/commonwealth/client/scripts/navigation/CommonDomainRoutes.tsx b/packages/commonwealth/client/scripts/navigation/CommonDomainRoutes.tsx index 1beadf2bb36..3e218fa3a19 100644 --- a/packages/commonwealth/client/scripts/navigation/CommonDomainRoutes.tsx +++ b/packages/commonwealth/client/scripts/navigation/CommonDomainRoutes.tsx @@ -118,7 +118,7 @@ const CommunityNotFoundPage = lazy( const CommonDomainRoutes = ({ contestEnabled, - farcasterContestEnabled, + weightedTopicsEnabled, tokenizedCommunityEnabled, }: RouteFeatureFlags) => [ import('views/pages/profile_redirect')); const CustomDomainRoutes = ({ contestEnabled, - farcasterContestEnabled, + weightedTopicsEnabled, tokenizedCommunityEnabled, }: RouteFeatureFlags) => { return [ @@ -307,7 +307,7 @@ const CustomDomainRoutes = ({ key="/manage/topics" path="/manage/topics" element={withLayout( - farcasterContestEnabled ? CommunityTopics : CommunityTopicsOld, + weightedTopicsEnabled ? CommunityTopics : CommunityTopicsOld, { scoped: true, }, diff --git a/packages/commonwealth/client/scripts/navigation/Router.tsx b/packages/commonwealth/client/scripts/navigation/Router.tsx index 2a05f794587..3b9189af29f 100644 --- a/packages/commonwealth/client/scripts/navigation/Router.tsx +++ b/packages/commonwealth/client/scripts/navigation/Router.tsx @@ -14,7 +14,7 @@ import GeneralRoutes from './GeneralRoutes'; export type RouteFeatureFlags = { contestEnabled: boolean; - farcasterContestEnabled: boolean; + weightedTopicsEnabled: boolean; tokenizedCommunityEnabled: boolean; }; @@ -22,10 +22,7 @@ const Router = () => { const client = OpenFeature.getClient(); const contestEnabled = client.getBooleanValue('contest', false); - const farcasterContestEnabled = client.getBooleanValue( - 'farcasterContest', - false, - ); + const weightedTopicsEnabled = client.getBooleanValue('weightedTopics', false); const tokenizedCommunityEnabled = client.getBooleanValue( 'tokenizedCommunity', @@ -34,7 +31,7 @@ const Router = () => { const flags = { contestEnabled, - farcasterContestEnabled, + weightedTopicsEnabled, tokenizedCommunityEnabled, }; diff --git a/packages/commonwealth/client/scripts/state/api/trpc/subscription/useSubscriptionPreferences.ts b/packages/commonwealth/client/scripts/state/api/trpc/subscription/useSubscriptionPreferences.ts new file mode 100644 index 00000000000..3500e90df10 --- /dev/null +++ b/packages/commonwealth/client/scripts/state/api/trpc/subscription/useSubscriptionPreferences.ts @@ -0,0 +1,5 @@ +import { trpc } from 'utils/trpcClient'; + +export function useSubscriptionPreferences() { + return trpc.subscription.getSubscriptionPreferences.useQuery({}); +} diff --git a/packages/commonwealth/client/scripts/views/components/component_kit/cw_breadcrumbs.tsx b/packages/commonwealth/client/scripts/views/components/component_kit/cw_breadcrumbs.tsx index 33fc586ad32..dcda32e35cc 100644 --- a/packages/commonwealth/client/scripts/views/components/component_kit/cw_breadcrumbs.tsx +++ b/packages/commonwealth/client/scripts/views/components/component_kit/cw_breadcrumbs.tsx @@ -20,6 +20,21 @@ type BreadcrumbsProps = { tooltipStr?: string; }; +const handleNavigation = (label, navigate, isParent) => { + if (label === 'Discussions' && isParent) { + navigate(`/discussions`); + } +}; +const handleMouseInteraction = ( + label: string, + handleInteraction: (event: React.MouseEvent) => void, + event: React.MouseEvent, +) => { + if (label !== 'Discussions') { + handleInteraction(event); + } +}; + export const CWBreadcrumbs = ({ breadcrumbs, tooltipStr, @@ -36,14 +51,19 @@ export const CWBreadcrumbs = ({ placement="bottom" renderTrigger={(handleInteraction) => ( + handleMouseInteraction(label, handleInteraction, event) + } + onMouseLeave={(event) => + handleMouseInteraction(label, handleInteraction, event) + } type="caption" className={clsx({ 'disable-active-cursor': index === 0, 'current-text': isCurrent, 'parent-text': !isCurrent, })} + onClick={() => handleNavigation(label, navigate, isParent)} > {truncateText(label)} diff --git a/packages/commonwealth/client/scripts/views/pages/404.scss b/packages/commonwealth/client/scripts/views/pages/404.scss new file mode 100644 index 00000000000..29d692239b0 --- /dev/null +++ b/packages/commonwealth/client/scripts/views/pages/404.scss @@ -0,0 +1,6 @@ +.PageNotFound { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; +} diff --git a/packages/commonwealth/client/scripts/views/pages/404.tsx b/packages/commonwealth/client/scripts/views/pages/404.tsx index b1ab5d4cfeb..85a513ddee6 100644 --- a/packages/commonwealth/client/scripts/views/pages/404.tsx +++ b/packages/commonwealth/client/scripts/views/pages/404.tsx @@ -1,21 +1,44 @@ import React from 'react'; +import useUserStore from 'state/ui/user'; +import { useAuthModalStore } from '../../state/ui/modals'; import { CWEmptyState } from '../components/component_kit/cw_empty_state'; +import { CWButton } from '../components/component_kit/new_designs/CWButton'; +import { AuthModal, AuthModalType } from '../modals/AuthModal'; +import './404.scss'; type PageNotFoundProps = { title?: string; message?: string }; export const PageNotFound = (props: PageNotFoundProps) => { const { message } = props; + const user = useUserStore(); + + const { authModalType, setAuthModalType } = useAuthModalStore(); + return ( - + + } + /> + {!user.isLoggedIn && ( + setAuthModalType(AuthModalType.SignIn)} + /> + )} + setAuthModalType(undefined)} + isOpen={!!authModalType} + /> + ); }; diff --git a/packages/commonwealth/client/scripts/views/pages/CommunityManagement/Contests/AdminContestsPage/AdminContestsPage.tsx b/packages/commonwealth/client/scripts/views/pages/CommunityManagement/Contests/AdminContestsPage/AdminContestsPage.tsx index 740a6d467a5..787ca182d90 100644 --- a/packages/commonwealth/client/scripts/views/pages/CommunityManagement/Contests/AdminContestsPage/AdminContestsPage.tsx +++ b/packages/commonwealth/client/scripts/views/pages/CommunityManagement/Contests/AdminContestsPage/AdminContestsPage.tsx @@ -12,6 +12,7 @@ import { } from 'shared/analytics/types'; import app from 'state'; import useGetFeeManagerBalanceQuery from 'state/api/communityStake/getFeeManagerBalance'; +import { useFetchTopicsQuery } from 'state/api/topics'; import useUserStore from 'state/ui/user'; import Permissions from 'utils/Permissions'; import { useCommunityStake } from 'views/components/CommunityStake'; @@ -30,6 +31,7 @@ import './AdminContestsPage.scss'; const AdminContestsPage = () => { const farcasterContestEnabled = useFlag('farcasterContest'); + const weightedTopicsEnabled = useFlag('weightedTopics'); const [contestView, setContestView] = useState(ContestView.List); const navigate = useCommonNavigate(); @@ -41,6 +43,7 @@ const AdminContestsPage = () => { const ethChainId = app?.chain?.meta?.ChainNode?.eth_chain_id || 0; const { stakeData } = useCommunityStake(); const namespace = stakeData?.Community?.namespace; + const communityId = app.activeChainId() || ''; const { trackAnalytics } = useBrowserAnalyticsTrack({ onAction: true, @@ -53,11 +56,23 @@ const AdminContestsPage = () => { isContestDataLoading, } = useCommunityContests(); + const { data: topicData } = useFetchTopicsQuery({ + communityId, + apiEnabled: !!communityId, + }); + + const hasAtLeastOneWeightedVotingTopic = topicData?.some( + (t) => t.weightedVoting, + ); + const { data: feeManagerBalance, isLoading: isFeeManagerBalanceLoading } = useGetFeeManagerBalanceQuery({ ethChainId: ethChainId!, namespace, - apiEnabled: !!ethChainId && !!namespace && stakeEnabled, + apiEnabled: + !!ethChainId && !!namespace && weightedTopicsEnabled + ? true + : stakeEnabled, }); const handleCreateContestClicked = () => { @@ -76,7 +91,10 @@ const AdminContestsPage = () => { } const showBanner = - stakeEnabled && isContestAvailable && ethChainId && namespace; + (weightedTopicsEnabled ? hasAtLeastOneWeightedVotingTopic : stakeEnabled) && + isContestAvailable && + ethChainId && + namespace; return ( @@ -84,13 +102,16 @@ const AdminContestsPage = () => {
Contests - {stakeEnabled && contestView !== ContestView.TypeSelection && ( - - )} + {(farcasterContestEnabled + ? hasAtLeastOneWeightedVotingTopic + : stakeEnabled) && + contestView !== ContestView.TypeSelection && ( + + )}
{contestView === ContestView.List ? ( @@ -106,6 +127,7 @@ const AdminContestsPage = () => { contests={contestsData} isLoading={isContestDataLoading} isAdmin={isAdmin} + hasWeightedTopic={!!hasAtLeastOneWeightedVotingTopic} isContestAvailable={isContestAvailable} stakeEnabled={stakeEnabled} feeManagerBalance={feeManagerBalance} diff --git a/packages/commonwealth/client/scripts/views/pages/CommunityManagement/Contests/ContestsList/ContestCard/ContestCard.tsx b/packages/commonwealth/client/scripts/views/pages/CommunityManagement/Contests/ContestsList/ContestCard/ContestCard.tsx index 81ddeac7922..c854e1ddde5 100644 --- a/packages/commonwealth/client/scripts/views/pages/CommunityManagement/Contests/ContestsList/ContestCard/ContestCard.tsx +++ b/packages/commonwealth/client/scripts/views/pages/CommunityManagement/Contests/ContestsList/ContestCard/ContestCard.tsx @@ -192,7 +192,7 @@ const ContestCard = ({ )} - Topics: {topics.map(({ name: topicName }) => topicName).join(', ')} + Topic: {topics.map(({ name: topicName }) => topicName).join(', ')} <> diff --git a/packages/commonwealth/client/scripts/views/pages/CommunityManagement/Contests/ContestsList/ContestsList.tsx b/packages/commonwealth/client/scripts/views/pages/CommunityManagement/Contests/ContestsList/ContestsList.tsx index 1f5b1360538..f749f8b8b8c 100644 --- a/packages/commonwealth/client/scripts/views/pages/CommunityManagement/Contests/ContestsList/ContestsList.tsx +++ b/packages/commonwealth/client/scripts/views/pages/CommunityManagement/Contests/ContestsList/ContestsList.tsx @@ -1,6 +1,7 @@ import moment from 'moment'; import React, { useState } from 'react'; +import { useFlag } from 'hooks/useFlag'; import { Skeleton } from 'views/components/Skeleton'; import EmptyContestsList from '../EmptyContestsList'; @@ -42,21 +43,25 @@ interface ContestsListProps { contests: Contest[]; isAdmin: boolean; isLoading: boolean; + hasWeightedTopic: boolean; stakeEnabled: boolean; isContestAvailable: boolean; feeManagerBalance?: string; onSetContestSelectionView?: () => void; } + const ContestsList = ({ contests, isAdmin, isLoading, + hasWeightedTopic, stakeEnabled, isContestAvailable, feeManagerBalance, onSetContestSelectionView, }: ContestsListProps) => { const [fundDrawerContest, setFundDrawerContest] = useState(); + const weightedTopicsEnabled = useFlag('weightedTopics'); if (isLoading) { return ( @@ -71,8 +76,11 @@ const ContestsList = ({ return ( <>
- {isAdmin && (!stakeEnabled || !isContestAvailable) ? ( + {isAdmin && + ((weightedTopicsEnabled ? !hasWeightedTopic : !stakeEnabled) || + !isContestAvailable) ? ( void; + hasWeightedTopic: boolean; } const EmptyContestsList = ({ isStakeEnabled, isContestAvailable, onSetContestSelectionView, + hasWeightedTopic, }: EmptyContestsListProps) => { const navigate = useCommonNavigate(); const farcasterContestEnabled = useFlag('farcasterContest'); + const weightedTopicsEnabled = useFlag('weightedTopics'); return (
- {!isStakeEnabled ? ( + {(weightedTopicsEnabled ? !hasWeightedTopic : !isStakeEnabled) ? ( navigate('/manage/integrations'), - }} + title={ + weightedTopicsEnabled + ? 'You must have at least one topic with weighted voting enabled to run contest' + : 'You must enable Community Stake' + } + subtitle={ + weightedTopicsEnabled + ? 'Setting up a contest just takes a few minutes and can be a huge boost to your community.' + : 'Contests require Community Stake...' + } + button={ + weightedTopicsEnabled + ? { + label: 'Create a topic', + handler: () => navigate('/manage/topics'), + } + : { + label: 'Enable Community Stake', + handler: () => navigate('/manage/integrations'), + } + } /> ) : !isContestAvailable ? ( { const [launchContestStep, setLaunchContestStep] = useState('DetailsForm'); const [createdContestAddress, setCreatedContestAddress] = useState(''); + const weightedTopicsEnabled = useFlag('weightedTopics'); const user = useUserStore(); @@ -44,7 +46,7 @@ const ManageContest = ({ contestAddress }: ManageContestProps) => { if ( !user.isLoggedIn || - !stakeEnabled || + (weightedTopicsEnabled ? false : !stakeEnabled) || !(Permissions.isSiteAdmin() || Permissions.isCommunityAdmin()) || contestNotFound ) { @@ -81,7 +83,7 @@ const ManageContest = ({ contestAddress }: ManageContestProps) => { return ( diff --git a/packages/commonwealth/client/scripts/views/pages/CommunityManagement/Contests/ManageContest/steps/DetailsFormStep/DetailsFormStep.scss b/packages/commonwealth/client/scripts/views/pages/CommunityManagement/Contests/ManageContest/steps/DetailsFormStep/DetailsFormStep.scss index b9f720f5eb2..4186b195fae 100644 --- a/packages/commonwealth/client/scripts/views/pages/CommunityManagement/Contests/ManageContest/steps/DetailsFormStep/DetailsFormStep.scss +++ b/packages/commonwealth/client/scripts/views/pages/CommunityManagement/Contests/ManageContest/steps/DetailsFormStep/DetailsFormStep.scss @@ -52,6 +52,10 @@ margin-bottom: 64px; } + .contest-section-topic { + margin-bottom: 64px; + } + .contest-section-description { margin-bottom: 64px; @@ -83,7 +87,7 @@ } .contest-section-funding { - margin-bottom: 64px; + margin-bottom: 32px; } .contest-section-duration { @@ -97,28 +101,26 @@ } } - .contest-section-recurring { - .prize-subsection { - margin-top: 24px; + .prize-subsection { + margin-top: 24px; - .percentage-buttons { - margin-top: 8px; - display: flex; - align-items: center; - justify-content: space-between; - gap: 16px; - flex-wrap: wrap; + .percentage-buttons { + margin-top: 8px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + flex-wrap: wrap; - @include extraSmall { - gap: unset; - } + @include extraSmall { + gap: unset; + } - .Button { - padding-inline: 36px; + .Button { + padding-inline: 36px; - @include extraSmall { - padding-inline: 10px; - } + @include extraSmall { + padding-inline: 10px; } } } @@ -252,6 +254,8 @@ .contest-name-input, .funding-token-address-input { + margin-bottom: 32px; + .MessageRow:first-child { display: none; // hide input label } diff --git a/packages/commonwealth/client/scripts/views/pages/CommunityManagement/Contests/ManageContest/steps/DetailsFormStep/DetailsFormStep.tsx b/packages/commonwealth/client/scripts/views/pages/CommunityManagement/Contests/ManageContest/steps/DetailsFormStep/DetailsFormStep.tsx index 3dcc63b955d..df11a0a264e 100644 --- a/packages/commonwealth/client/scripts/views/pages/CommunityManagement/Contests/ManageContest/steps/DetailsFormStep/DetailsFormStep.tsx +++ b/packages/commonwealth/client/scripts/views/pages/CommunityManagement/Contests/ManageContest/steps/DetailsFormStep/DetailsFormStep.tsx @@ -1,11 +1,14 @@ import React, { useState } from 'react'; import { useSearchParams } from 'react-router-dom'; +import { TopicWeightedVoting } from '@hicommonwealth/schemas'; import { notifyError } from 'controllers/app/notifications'; import { useFlag } from 'hooks/useFlag'; import { useCommonNavigate } from 'navigation/helpers'; import app from 'state'; import useUpdateContestMutation from 'state/api/contests/updateContest'; +import { useFetchTopicsQuery } from 'state/api/topics'; +import TokenFinder, { useTokenFinder } from 'views/components/TokenFinder'; import { CWCoverImageUploader, ImageBehavior, @@ -16,6 +19,7 @@ import { CWText } from 'views/components/component_kit/cw_text'; import { CWTextArea } from 'views/components/component_kit/cw_text_area'; import { CWButton } from 'views/components/component_kit/new_designs/CWButton'; import { CWForm } from 'views/components/component_kit/new_designs/CWForm'; +import { CWSelectList } from 'views/components/component_kit/new_designs/CWSelectList'; import { CWTextInput } from 'views/components/component_kit/new_designs/CWTextInput'; import { MessageRow } from 'views/components/component_kit/new_designs/CWTextInput/MessageRow'; import { CWRadioButton } from 'views/components/component_kit/new_designs/cw_radio_button'; @@ -24,7 +28,6 @@ import { openConfirmation } from 'views/modals/confirmation_modal'; import { ContestType } from 'views/pages/CommunityManagement/Contests/types'; import CommunityManagementLayout from 'views/pages/CommunityManagement/common/CommunityManagementLayout'; -import TokenFinder, { useTokenFinder } from 'views/components/TokenFinder'; import { CONTEST_FAQ_URL } from '../../../utils'; import { ContestFeeType, @@ -40,10 +43,11 @@ import { INITIAL_PERCENTAGE_VALUE, MAX_WINNERS, MIN_WINNERS, - farcasterDurationOptions, - initialFarcasterDuration, + contestDurationOptions, + initialContestDuration, initialPayoutStructure, prizePercentageOptions, + weightedVotingValueToLabel, } from './utils'; import { detailsFormValidationSchema } from './validation'; @@ -62,6 +66,7 @@ const DetailsFormStep = ({ }: DetailsFormStepProps) => { const navigate = useCommonNavigate(); const farcasterContestEnabled = useFlag('farcasterContest'); + const weightedTopicsEnabled = useFlag('weightedTopics'); const [searchParams] = useSearchParams(); const contestType = searchParams.get('type'); const isFarcasterContest = contestType === ContestType.Farcaster; @@ -72,11 +77,9 @@ const DetailsFormStep = ({ const [prizePercentage, setPrizePercentage] = useState< ContestFormData['prizePercentage'] >(contestFormData?.prizePercentage || INITIAL_PERCENTAGE_VALUE); - const [farcasterContestDuration, setFarcasterContestDuration] = useState< - number | undefined - >( - isFarcasterContest - ? contestFormData?.farcasterContestDuration || initialFarcasterDuration + const [contestDuration, setContestDuration] = useState( + weightedTopicsEnabled + ? contestFormData?.contestDuration || initialContestDuration : undefined, ); @@ -106,6 +109,12 @@ const DetailsFormStep = ({ chainId: chainId, }); + const communityId = app.activeChainId() || ''; + const { data: topicsData } = useFetchTopicsQuery({ + communityId, + apiEnabled: !!communityId, + }); + const editMode = !!contestAddress; const payoutRowError = payoutStructure.some((payout) => payout < 1); const totalPayoutPercentage = payoutStructure.reduce( @@ -114,9 +123,21 @@ const DetailsFormStep = ({ ); const totalPayoutPercentageError = totalPayoutPercentage !== 100; + const weightedTopics = (topicsData || []) + .filter((t) => t?.weightedVoting) + .map((t) => ({ + value: t.id, + label: t.name, + weightedVoting: t.weightedVoting, + helpText: weightedVotingValueToLabel(t.weightedVoting!), + })); + const getInitialValues = () => { return { contestName: contestFormData?.contestName, + contestTopic: weightedTopics.find( + (t) => t.value === contestFormData?.contestTopic?.value, + ), contestDescription: contestFormData?.contestDescription, contestImage: contestFormData?.contestImage, feeType: @@ -167,23 +188,40 @@ const DetailsFormStep = ({ }; const handleSubmit = async (values: ContestFormValidationSubmitValues) => { - const topicsError = !isFarcasterContest && topicsEnabledError; + const topicsError = !weightedTopicsEnabled && topicsEnabledError; if (totalPayoutPercentageError || payoutRowError || topicsError) { return; } + const selectedTopic = (weightedTopics || []).find( + (t) => t.value === values?.contestTopic?.value, + ); + + const feeType = weightedTopicsEnabled + ? selectedTopic?.weightedVoting === TopicWeightedVoting.ERC20 + ? ContestFeeType.DirectDeposit + : ContestFeeType.CommunityStake + : values.feeType; + + const contestRecurring = weightedTopicsEnabled + ? selectedTopic?.weightedVoting === TopicWeightedVoting.ERC20 + ? ContestRecurringType.No + : ContestRecurringType.Yes + : values.contestRecurring; + const formData: ContestFormData = { contestName: values.contestName, contestDescription: values.contestDescription, contestImage: values.contestImage, - feeType: values.feeType, fundingTokenAddress: values.fundingTokenAddress, - contestRecurring: values.contestRecurring, + contestTopic: selectedTopic, + contestRecurring, + feeType, prizePercentage, payoutStructure, toggledTopicList, - farcasterContestDuration, + contestDuration, }; if (editMode) { @@ -250,9 +288,31 @@ const DetailsFormStep = ({ validationSchema={detailsFormValidationSchema} onSubmit={handleSubmit} initialValues={getInitialValues()} + onErrors={console.error} > {({ watch, setValue }) => ( <> + {weightedTopicsEnabled && !isFarcasterContest && ( +
+ Choose a topic + + Select which topic you would like to include in this + contest. Only threads posted to this topic will be eligible + for the contest prizes. + + + +
+ )} +
Name your contest @@ -307,46 +367,152 @@ const DetailsFormStep = ({ - {farcasterContestEnabled && isFarcasterContest ? ( - <> -
- Fund your contest - - Enter the address of the token you would like to use to - fund your contest - - - -
- -
-
- Contest duration + {weightedTopicsEnabled ? ( + isFarcasterContest ? ( + <> +
+ Fund your contest - How long would you like your contest to run? + Enter the address of the token you would like to use to + fund your contest + +
- o.value === farcasterContestDuration, - )} - onChange={(newValue) => { - setFarcasterContestDuration(newValue?.value); - }} - /> -
- +
+
+ Contest duration + + How long would you like your contest to run? + +
+ + o.value === contestDuration, + )} + onChange={(newValue) => { + setContestDuration(newValue?.value); + }} + isDisabled={editMode} + /> +
+ + ) : ( + <> + {weightedTopics.find( + (t) => t.value === watch('contestTopic')?.value, + )?.weightedVoting === TopicWeightedVoting.ERC20 ? ( + <> +
+ Contest Funding + + Enter the token address to set as your funding + method. + +
+ + Token address + + + + ) : weightedTopics.find( + (t) => t.value === watch('contestTopic')?.value, + )?.weightedVoting === TopicWeightedVoting.Stake ? ( + <> +
+ Contest Funding + + Set the amount of community stake you want to + allocate for your contest. + + +
+ + How much of the funds would you like to use + weekly? + + + All community stake funded contests are recurring + weekly. +
+ Tip: smaller prizes makes the contest run longer +
+
+ {prizePercentageOptions.map( + ({ value, label }) => ( + setPrizePercentage(value)} + buttonType={ + prizePercentage === value + ? 'primary' + : 'secondary' + } + /> + ), + )} +
+
+
+ + ) : ( + <> + )} + +
+
+ Contest duration + + How long would you like your contest to run? + +
+ + o.value === contestDuration, + )} + onChange={(newValue) => { + setContestDuration(newValue?.value); + }} + isDisabled={editMode} + /> +
+ + + + ) ) : ( <>
@@ -392,7 +558,11 @@ const DetailsFormStep = ({ debouncedTokenValue={debouncedTokenValue} tokenMetadataLoading={tokenMetadataLoading} tokenMetadata={tokenMetadata} - tokenValue={tokenValue} + tokenValue={ + editMode + ? contestFormData?.fundingTokenAddress || '' + : tokenValue + } setTokenValue={setTokenValue} tokenError={getTokenError()} containerClassName="funding-token-address-input" @@ -546,7 +716,7 @@ const DetailsFormStep = ({
- {farcasterContestEnabled && isFarcasterContest ? ( + {weightedTopicsEnabled ? ( <> ) : ( <> diff --git a/packages/commonwealth/client/scripts/views/pages/CommunityManagement/Contests/ManageContest/steps/DetailsFormStep/utils.ts b/packages/commonwealth/client/scripts/views/pages/CommunityManagement/Contests/ManageContest/steps/DetailsFormStep/utils.ts index d54c5df673b..e29d756e93b 100644 --- a/packages/commonwealth/client/scripts/views/pages/CommunityManagement/Contests/ManageContest/steps/DetailsFormStep/utils.ts +++ b/packages/commonwealth/client/scripts/views/pages/CommunityManagement/Contests/ManageContest/steps/DetailsFormStep/utils.ts @@ -1,3 +1,4 @@ +import { TopicWeightedVoting } from '@hicommonwealth/schemas'; import colors from '../../../../../../../../styles/mixins/colors.module.scss'; export const INITIAL_PERCENTAGE_VALUE = 10; @@ -47,7 +48,7 @@ export const getPrizeColor = (index: number) => { export const DAY_IN_SECONDS = 24 * 60 * 60; -export const farcasterDurationOptions = Array.from({ length: 7 }, (_, i) => { +export const contestDurationOptions = Array.from({ length: 7 }, (_, i) => { const days = i + 1; return { label: `${days} Day${days > 1 ? 's' : ''}`, @@ -55,4 +56,18 @@ export const farcasterDurationOptions = Array.from({ length: 7 }, (_, i) => { }; }); -export const initialFarcasterDuration = farcasterDurationOptions[6].value; +export const initialContestDuration = contestDurationOptions[6].value; + +export const weightedVotingValueToLabel = ( + weightedVoting: TopicWeightedVoting, +) => { + if (weightedVoting === TopicWeightedVoting.Stake) { + return 'Community Stake'; + } + + if (weightedVoting === TopicWeightedVoting.ERC20) { + return 'ERC20'; + } + + return ''; +}; diff --git a/packages/commonwealth/client/scripts/views/pages/CommunityManagement/Contests/ManageContest/steps/DetailsFormStep/validation.ts b/packages/commonwealth/client/scripts/views/pages/CommunityManagement/Contests/ManageContest/steps/DetailsFormStep/validation.ts index c39e7974d75..3372409a650 100644 --- a/packages/commonwealth/client/scripts/views/pages/CommunityManagement/Contests/ManageContest/steps/DetailsFormStep/validation.ts +++ b/packages/commonwealth/client/scripts/views/pages/CommunityManagement/Contests/ManageContest/steps/DetailsFormStep/validation.ts @@ -1,7 +1,12 @@ +import { TopicWeightedVoting } from '@hicommonwealth/schemas'; +import { OpenFeature } from '@openfeature/web-sdk'; import { VALIDATION_MESSAGES } from 'helpers/formValidations/messages'; import { ContestFeeType } from 'views/pages/CommunityManagement/Contests/ManageContest/types'; import z from 'zod'; +const client = OpenFeature.getClient(); +const weightedTopicsEnabled = client.getBooleanValue('weightedTopics', false); + export const detailsFormValidationSchema = z.object({ contestName: z .string() @@ -9,6 +14,17 @@ export const detailsFormValidationSchema = z.object({ .max(255, { message: VALIDATION_MESSAGES.MAX_CHAR_LIMIT_REACHED }), contestDescription: z.string().optional(), contestImage: z.string().optional(), + contestTopic: z + .object({ + value: z.number().optional(), + label: z.string(), + helpText: z.string().optional(), + weightedVoting: z.nativeEnum(TopicWeightedVoting).optional().nullish(), + }) + .optional() + .refine((value) => (weightedTopicsEnabled ? !!value : true), { + message: 'You must select a topic', + }), feeType: z.enum([ ContestFeeType.CommunityStake, ContestFeeType.DirectDeposit, diff --git a/packages/commonwealth/client/scripts/views/pages/CommunityManagement/Contests/ManageContest/steps/SignTransactionsStep/SignTransactionsStep.tsx b/packages/commonwealth/client/scripts/views/pages/CommunityManagement/Contests/ManageContest/steps/SignTransactionsStep/SignTransactionsStep.tsx index 0f3cf0f46d8..5be63af9567 100644 --- a/packages/commonwealth/client/scripts/views/pages/CommunityManagement/Contests/ManageContest/steps/SignTransactionsStep/SignTransactionsStep.tsx +++ b/packages/commonwealth/client/scripts/views/pages/CommunityManagement/Contests/ManageContest/steps/SignTransactionsStep/SignTransactionsStep.tsx @@ -49,6 +49,8 @@ const SignTransactionsStep = ({ onSetCreatedContestAddress, fundingTokenTicker, }: SignTransactionsStepProps) => { + const weightedTopicsEnabled = useFlag('weightedTopics'); + const [launchContestData, setLaunchContestData] = useState({ state: 'not-started' as ActionStepProps['state'], errorText: '', @@ -81,14 +83,18 @@ const SignTransactionsStep = ({ const namespaceName = app?.chain?.meta?.namespace; const contestLength = devContest ? ONE_HOUR_IN_SECONDS - : SEVEN_DAYS_IN_SECONDS; + : weightedTopicsEnabled + ? contestFormData?.contestDuration + : SEVEN_DAYS_IN_SECONDS; const stakeId = stakeData?.stake_id; const voterShare = commonProtocol.CONTEST_VOTER_SHARE; const feeShare = commonProtocol.CONTEST_FEE_SHARE; const weight = stakeData?.vote_weight; const contestInterval = devContest ? ONE_HOUR_IN_SECONDS - : SEVEN_DAYS_IN_SECONDS; + : weightedTopicsEnabled + ? contestFormData?.contestDuration + : SEVEN_DAYS_IN_SECONDS; const prizeShare = contestFormData?.prizePercentage; const walletAddress = user.activeAccount?.address; const exchangeToken = isDirectDepositSelected @@ -149,10 +155,12 @@ const SignTransactionsStep = ({ ? contestFormData?.prizePercentage : 0, payout_structure: contestFormData?.payoutStructure, - interval: isContestRecurring ? contestInterval : 0, - topic_ids: contestFormData?.toggledTopicList - .filter((t) => t.checked) - .map((t) => t.id!), + interval: isContestRecurring ? contestInterval! : 0, + topic_ids: weightedTopicsEnabled + ? [contestFormData?.contestTopic?.value as number] + : contestFormData?.toggledTopicList + .filter((t) => t.checked) + .map((t) => t.id!), ticker: fundingTokenTicker, }); diff --git a/packages/commonwealth/client/scripts/views/pages/CommunityManagement/Contests/ManageContest/types.ts b/packages/commonwealth/client/scripts/views/pages/CommunityManagement/Contests/ManageContest/types.ts index e2ab62d6b61..5445a7d5138 100644 --- a/packages/commonwealth/client/scripts/views/pages/CommunityManagement/Contests/ManageContest/types.ts +++ b/packages/commonwealth/client/scripts/views/pages/CommunityManagement/Contests/ManageContest/types.ts @@ -23,6 +23,6 @@ export type ContestFormValidationSubmitValues = z.infer< export type ContestFormData = ContestFormValidationSubmitValues & { prizePercentage: number; payoutStructure: number[]; - farcasterContestDuration?: number; + contestDuration?: number; toggledTopicList: { name: string; id?: number; checked: boolean }[]; }; diff --git a/packages/commonwealth/client/scripts/views/pages/CommunityManagement/Contests/ManageContest/useManageContestForm.ts b/packages/commonwealth/client/scripts/views/pages/CommunityManagement/Contests/ManageContest/useManageContestForm.ts index 267356b3c92..a49fd15e783 100644 --- a/packages/commonwealth/client/scripts/views/pages/CommunityManagement/Contests/ManageContest/useManageContestForm.ts +++ b/packages/commonwealth/client/scripts/views/pages/CommunityManagement/Contests/ManageContest/useManageContestForm.ts @@ -1,5 +1,6 @@ import { useEffect, useState } from 'react'; +import moment from 'moment'; import useCommunityContests from 'views/pages/CommunityManagement/Contests/useCommunityContests'; import { ContestFeeType, ContestFormData, ContestRecurringType } from './types'; @@ -26,9 +27,21 @@ const useManageContestForm = ({ return; } + const contestLengthInSeconds = moment( + contestData?.contests?.[0]?.end_time, + ).diff(contestData?.contests?.[0]?.start_time, 'seconds'); + setContestFormData({ contestName: contestData.name, contestImage: contestData.image_url!, + contestTopic: { + value: contestData.topics[0]?.id, + label: contestData.topics[0]?.name, + }, + contestDuration: + contestData.interval === 0 + ? contestLengthInSeconds + : contestData.interval, feeType: contestData.funding_token_address ? ContestFeeType.DirectDeposit : ContestFeeType.CommunityStake, diff --git a/packages/commonwealth/client/scripts/views/pages/Contests/Contests.tsx b/packages/commonwealth/client/scripts/views/pages/Contests/Contests.tsx index 60da59cbc24..728cd4d750f 100644 --- a/packages/commonwealth/client/scripts/views/pages/Contests/Contests.tsx +++ b/packages/commonwealth/client/scripts/views/pages/Contests/Contests.tsx @@ -7,9 +7,12 @@ import CWPageLayout from 'views/components/component_kit/new_designs/CWPageLayou import ContestsList from 'views/pages/CommunityManagement/Contests/ContestsList'; import useCommunityContests from 'views/pages/CommunityManagement/Contests/useCommunityContests'; +import { useFlag } from 'hooks/useFlag'; import './Contests.scss'; const Contests = () => { + const weightedTopicsEnabled = useFlag('weightedTopics'); + const { stakeEnabled, contestsData, @@ -17,7 +20,10 @@ const Contests = () => { isContestDataLoading, } = useCommunityContests(); - if (!isContestDataLoading && (!stakeEnabled || !isContestAvailable)) { + if ( + !isContestDataLoading && + ((weightedTopicsEnabled ? false : !stakeEnabled) || !isContestAvailable) + ) { return ; } @@ -34,8 +40,9 @@ const Contests = () => {
diff --git a/packages/commonwealth/client/scripts/views/pages/CreateCommunity/useCreateCommunity.ts b/packages/commonwealth/client/scripts/views/pages/CreateCommunity/useCreateCommunity.ts index 7493ef7c1f0..8ff216ab2f8 100644 --- a/packages/commonwealth/client/scripts/views/pages/CreateCommunity/useCreateCommunity.ts +++ b/packages/commonwealth/client/scripts/views/pages/CreateCommunity/useCreateCommunity.ts @@ -13,7 +13,7 @@ const useCreateCommunity = () => { { type: null, chainBase: null }, ); - const weightedVotingEnabled = useFlag('farcasterContest'); + const weightedTopicsEnabled = useFlag('weightedTopics'); // @ts-expect-error StrictNullChecks const [selectedAddress, setSelectedAddress] = useState(null); @@ -49,7 +49,7 @@ const useCreateCommunity = () => { ); const showCommunityStakeStep = - !weightedVotingEnabled && + !weightedTopicsEnabled && isValidStepToShowCommunityStakeFormStep && isSupportedChainSelected; diff --git a/packages/commonwealth/client/scripts/views/pages/NotificationSettings/PushNotificationsToggle.tsx b/packages/commonwealth/client/scripts/views/pages/NotificationSettings/PushNotificationsToggle.tsx index a97947b6af8..2c40eecdd96 100644 --- a/packages/commonwealth/client/scripts/views/pages/NotificationSettings/PushNotificationsToggle.tsx +++ b/packages/commonwealth/client/scripts/views/pages/NotificationSettings/PushNotificationsToggle.tsx @@ -1,103 +1,21 @@ -import { BrowserType, getBrowserType } from 'helpers/browser'; -import React, { useCallback, useState } from 'react'; +import React from 'react'; // eslint-disable-next-line max-len -import { useRegisterClientRegistrationTokenMutation } from 'state/api/trpc/subscription/useRegisterClientRegistrationTokenMutation'; -// eslint-disable-next-line max-len -import { useUnregisterClientRegistrationTokenMutation } from 'state/api/trpc/subscription/useUnregisterClientRegistrationTokenMutation'; import { CWToggle } from 'views/components/component_kit/cw_toggle'; -import { getFirebaseMessagingToken } from 'views/pages/NotificationSettings/getFirebaseMessagingToken'; - -const LOCAL_STORAGE_KEY = 'pushNotificationsEnabled'; - -function computeChannelTypeFromBrowserType( - browserType: BrowserType | undefined, -): 'FCM' | 'APNS' | undefined { - switch (browserType) { - case 'safari': - return 'APNS'; - case 'chrome': - return 'FCM'; - } +import { + SubscriptionPrefType, + useSubscriptionPreferenceSettingCallback, +} from 'views/pages/NotificationSettings/useSubscriptionPreferenceSettingCallback'; - return undefined; +interface PushNotificationsToggleProps { + readonly pref: SubscriptionPrefType; } -export const PushNotificationsToggle = () => { - const registerClientRegistrationToken = - useRegisterClientRegistrationTokenMutation(); - - const unregisterClientRegistrationToken = - useUnregisterClientRegistrationTokenMutation(); - - const browserType = getBrowserType(); - const channelType = computeChannelTypeFromBrowserType(browserType) ?? 'APNS'; //cannot be undefined - - const [checked, setChecked] = useState( - () => localStorage.getItem(LOCAL_STORAGE_KEY) === 'on', - ); - - const handleButtonToggle = useCallback((newValue: boolean) => { - localStorage.setItem(LOCAL_STORAGE_KEY, newValue ? 'on' : 'off'); - setChecked(newValue); - }, []); - - const handleRegisterPushNotificationSubscription = useCallback(() => { - async function doAsync() { - const permission = await Notification.requestPermission(); - if (permission === 'granted') { - console.log('Notification permission granted.'); - const token = await getFirebaseMessagingToken(); - await registerClientRegistrationToken.mutateAsync({ - id: 0, // this should be the aggregate id (user?) - token, - channelType, - }); - } - } - - doAsync().catch(console.error); - }, [channelType, registerClientRegistrationToken]); - - const handleUnregisterPushNotificationSubscription = useCallback(() => { - async function doAsync() { - const permission = await Notification.requestPermission(); - if (permission === 'granted') { - console.log('Notification permission granted.'); - const token = await getFirebaseMessagingToken(); - await unregisterClientRegistrationToken.mutateAsync({ - id: 0, // this should be the aggregate id (user?) - token, - channelType, - }); - } - } - - doAsync().catch(console.error); - }, [channelType, unregisterClientRegistrationToken]); - - const handleRegistration = useCallback( - (newValue: boolean) => { - if (newValue) { - handleRegisterPushNotificationSubscription(); - } else { - handleUnregisterPushNotificationSubscription(); - } - }, - [ - handleRegisterPushNotificationSubscription, - handleUnregisterPushNotificationSubscription, - ], - ); +export const PushNotificationsToggle = ( + props: PushNotificationsToggleProps, +) => { + const { pref } = props; - const handleChecked = useCallback( - (newValue: boolean) => { - handleButtonToggle(newValue); - handleRegistration(newValue); - }, - [handleButtonToggle, handleRegistration], - ); + const [checked, activate] = useSubscriptionPreferenceSettingCallback(pref); - return ( - handleChecked(!checked)} /> - ); + return activate(!checked)} />; }; diff --git a/packages/commonwealth/client/scripts/views/pages/NotificationSettings/computeChannelTypeFromBrowserType.ts b/packages/commonwealth/client/scripts/views/pages/NotificationSettings/computeChannelTypeFromBrowserType.ts new file mode 100644 index 00000000000..e3b43e559ae --- /dev/null +++ b/packages/commonwealth/client/scripts/views/pages/NotificationSettings/computeChannelTypeFromBrowserType.ts @@ -0,0 +1,18 @@ +import { BrowserType } from 'helpers/browser'; + +/** + * Compute the channel for Knock notifications. Firebase cloud messaging or + * Apple. + */ +export function computeChannelTypeFromBrowserType( + browserType: BrowserType | undefined, +): 'FCM' | 'APNS' | undefined { + switch (browserType) { + case 'safari': + return 'APNS'; + case 'chrome': + return 'FCM'; + } + + return undefined; +} diff --git a/packages/commonwealth/client/scripts/views/pages/NotificationSettings/index.scss b/packages/commonwealth/client/scripts/views/pages/NotificationSettings/index.scss index fa8d4750205..65aff5f9e9d 100644 --- a/packages/commonwealth/client/scripts/views/pages/NotificationSettings/index.scss +++ b/packages/commonwealth/client/scripts/views/pages/NotificationSettings/index.scss @@ -5,7 +5,8 @@ .setting-container { display: flex; - margin-block: 8px; + margin-top: 16px; + margin-bottom: 32px; } .setting-container-right { diff --git a/packages/commonwealth/client/scripts/views/pages/NotificationSettings/index.tsx b/packages/commonwealth/client/scripts/views/pages/NotificationSettings/index.tsx index 91486a7bf4c..f8311767de6 100644 --- a/packages/commonwealth/client/scripts/views/pages/NotificationSettings/index.tsx +++ b/packages/commonwealth/client/scripts/views/pages/NotificationSettings/index.tsx @@ -115,19 +115,46 @@ const NotificationSettings = () => { <> {enableKnockPushNotifications && supportsPushNotifications && (
- - Push Notifications - +
+
+ Turn push notifications on/off + + + Turn off notifications to stop receiving any alerts on + your device. + +
+ +
+ +
+
+ +
+
+ Discussion Activity + + + Get notified when someone mentions you + +
+ +
+ +
+
+ Admin Alerts + - Turn on notifications to receive alerts on your device. + Notifications for communities you are an admin for
- +
diff --git a/packages/commonwealth/client/scripts/views/pages/NotificationSettings/usePushNotificationActivated.ts b/packages/commonwealth/client/scripts/views/pages/NotificationSettings/usePushNotificationActivated.ts new file mode 100644 index 00000000000..f75b6708762 --- /dev/null +++ b/packages/commonwealth/client/scripts/views/pages/NotificationSettings/usePushNotificationActivated.ts @@ -0,0 +1,18 @@ +import { useCallback, useState } from 'react'; + +const LOCAL_STORAGE_KEY = 'pushNotificationsEnabled'; + +export function usePushNotificationActivated(): Readonly< + [boolean, (newValue: boolean) => void] +> { + const [checked, setChecked] = useState( + () => localStorage.getItem(LOCAL_STORAGE_KEY) === 'on', + ); + + const toggle = useCallback((newValue: boolean) => { + localStorage.setItem(LOCAL_STORAGE_KEY, newValue ? 'on' : 'off'); + setChecked(newValue); + }, []); + + return [checked, toggle]; +} diff --git a/packages/commonwealth/client/scripts/views/pages/NotificationSettings/usePushNotificationToggleCallback.ts b/packages/commonwealth/client/scripts/views/pages/NotificationSettings/usePushNotificationToggleCallback.ts new file mode 100644 index 00000000000..bfbd1a0b606 --- /dev/null +++ b/packages/commonwealth/client/scripts/views/pages/NotificationSettings/usePushNotificationToggleCallback.ts @@ -0,0 +1,22 @@ +import { useCallback } from 'react'; +// eslint-disable-next-line max-len +import { useRegisterPushNotificationSubscriptionCallback } from 'views/pages/NotificationSettings/useRegisterPushNotificationSubscriptionCallback'; +// eslint-disable-next-line max-len +import { useUnregisterPushNotificationSubscriptionCallback } from 'views/pages/NotificationSettings/useUnregisterPushNotificationSubscriptionCallback'; + +export function usePushNotificationToggleCallback() { + const registerCallback = useRegisterPushNotificationSubscriptionCallback(); + const unregisterCallback = + useUnregisterPushNotificationSubscriptionCallback(); + + return useCallback( + async (active: boolean) => { + if (active) { + await registerCallback(); + } else { + await unregisterCallback(); + } + }, + [registerCallback, unregisterCallback], + ); +} diff --git a/packages/commonwealth/client/scripts/views/pages/NotificationSettings/useRegisterPushNotificationSubscriptionCallback.ts b/packages/commonwealth/client/scripts/views/pages/NotificationSettings/useRegisterPushNotificationSubscriptionCallback.ts new file mode 100644 index 00000000000..2e29b4dbcd5 --- /dev/null +++ b/packages/commonwealth/client/scripts/views/pages/NotificationSettings/useRegisterPushNotificationSubscriptionCallback.ts @@ -0,0 +1,33 @@ +import { getBrowserType } from 'helpers/browser'; +import { useCallback } from 'react'; +// eslint-disable-next-line max-len +import { useRegisterClientRegistrationTokenMutation } from 'state/api/trpc/subscription/useRegisterClientRegistrationTokenMutation'; +// eslint-disable-next-line max-len +import { computeChannelTypeFromBrowserType } from 'views/pages/NotificationSettings/computeChannelTypeFromBrowserType'; +// eslint-disable-next-line max-len +import useUserStore from 'state/ui/user'; +import { getFirebaseMessagingToken } from 'views/pages/NotificationSettings/getFirebaseMessagingToken'; + +export function useRegisterPushNotificationSubscriptionCallback() { + const registerClientRegistrationToken = + useRegisterClientRegistrationTokenMutation(); + const user = useUserStore(); + + return useCallback(async () => { + const browserType = getBrowserType(); + const channelType = computeChannelTypeFromBrowserType(browserType); + if (!channelType) return; + + const permission = await Notification.requestPermission(); + if (permission === 'granted') { + console.log('Notification permission granted.'); + const token = await getFirebaseMessagingToken(); + await registerClientRegistrationToken.mutateAsync({ + id: user.id, + token, + channelType, + }); + console.log('Push notifications registered.'); + } + }, [registerClientRegistrationToken, user.id]); +} diff --git a/packages/commonwealth/client/scripts/views/pages/NotificationSettings/useSubscriptionPreferenceSettingCallback.ts b/packages/commonwealth/client/scripts/views/pages/NotificationSettings/useSubscriptionPreferenceSettingCallback.ts new file mode 100644 index 00000000000..fa9124f9f70 --- /dev/null +++ b/packages/commonwealth/client/scripts/views/pages/NotificationSettings/useSubscriptionPreferenceSettingCallback.ts @@ -0,0 +1,90 @@ +import { useCallback } from 'react'; +// eslint-disable-next-line max-len +import { useSubscriptionPreferences } from 'state/api/trpc/subscription/useSubscriptionPreferences'; +// eslint-disable-next-line max-len +import { useUpdateSubscriptionPreferencesMutation } from 'state/api/trpc/subscription/useUpdateSubscriptionPreferencesMutation'; +// eslint-disable-next-line max-len +import { usePushNotificationActivated } from 'views/pages/NotificationSettings/usePushNotificationActivated'; +// eslint-disable-next-line max-len +import useUserStore from 'state/ui/user'; +import { usePushNotificationToggleCallback } from 'views/pages/NotificationSettings/usePushNotificationToggleCallback'; + +/** + * Return a boolean indicating if we're active, and a callback to toggle + * the activation. + */ +type UseSubscriptionPreferenceSettingCallbackResult = Readonly< + [boolean, (activate: boolean) => void] +>; + +export type SubscriptionPrefType = + | 'mobile_push_notifications_enabled' + | 'mobile_push_discussion_activity_enabled' + | 'mobile_push_admin_alerts_enabled'; + +export function useSubscriptionPreferenceSettingCallback( + pref: SubscriptionPrefType, +): UseSubscriptionPreferenceSettingCallbackResult { + const subscriptionPreferences = useSubscriptionPreferences(); + const { mutateAsync: updateSubscriptionPreferences } = + useUpdateSubscriptionPreferencesMutation(); + const pushNotificationToggleCallback = usePushNotificationToggleCallback(); + const [pushNotificationActivated, togglePushNotificationActivated] = + usePushNotificationActivated(); + const user = useUserStore(); + + const toggle = useCallback( + (activate: boolean) => { + async function doAsync() { + // ** first we set the subscription preference + await updateSubscriptionPreferences({ + id: user.id, + ...subscriptionPreferences.data, + [pref]: activate, + }); + + //** now we have to determine how to set push notifications. + + const pushNotificationsActive = + subscriptionPreferences.data?.['mobile_push_notifications_enabled'] || + subscriptionPreferences.data?.[ + 'mobile_push_discussion_activity_enabled' + ] || + subscriptionPreferences.data?.['mobile_push_admin_alerts_enabled']; + + await pushNotificationToggleCallback(pushNotificationsActive); + + togglePushNotificationActivated(pushNotificationsActive); + await subscriptionPreferences.refetch(); + } + + doAsync().catch(console.error); + }, + [ + pref, + pushNotificationToggleCallback, + subscriptionPreferences, + togglePushNotificationActivated, + updateSubscriptionPreferences, + user.id, + ], + ); + + function computeSubscriptionPreferenceActivated() { + if (subscriptionPreferences.data) { + // NOTE: trpc types are mangled again for some reason. I'm not sure why + // because useSubscriptionPreferences looks just like other hooks we've + // been using. I verified this typing is correct manually. We will have + // to look into why tRPC keeps mangling our types. + return subscriptionPreferences.data[pref] as boolean; + } else { + return false; + } + } + + return [ + // the local device has to be and the feature toggle has to be on. + pushNotificationActivated && computeSubscriptionPreferenceActivated(), + toggle, + ]; +} diff --git a/packages/commonwealth/client/scripts/views/pages/NotificationSettings/useUnregisterPushNotificationSubscriptionCallback.ts b/packages/commonwealth/client/scripts/views/pages/NotificationSettings/useUnregisterPushNotificationSubscriptionCallback.ts new file mode 100644 index 00000000000..3f86a743bde --- /dev/null +++ b/packages/commonwealth/client/scripts/views/pages/NotificationSettings/useUnregisterPushNotificationSubscriptionCallback.ts @@ -0,0 +1,34 @@ +import { getBrowserType } from 'helpers/browser'; +import { useCallback } from 'react'; +// eslint-disable-next-line max-len +import { useUnregisterClientRegistrationTokenMutation } from 'state/api/trpc/subscription/useUnregisterClientRegistrationTokenMutation'; +// eslint-disable-next-line max-len +import { computeChannelTypeFromBrowserType } from 'views/pages/NotificationSettings/computeChannelTypeFromBrowserType'; +// eslint-disable-next-line max-len +import useUserStore from 'state/ui/user'; +import { getFirebaseMessagingToken } from 'views/pages/NotificationSettings/getFirebaseMessagingToken'; + +export function useUnregisterPushNotificationSubscriptionCallback() { + const unregisterClientRegistrationToken = + useUnregisterClientRegistrationTokenMutation(); + const user = useUserStore(); + + return useCallback(async () => { + const browserType = getBrowserType(); + const channelType = computeChannelTypeFromBrowserType(browserType); + + if (!channelType) return; + + const permission = await Notification.requestPermission(); + if (permission === 'granted') { + console.log('Notification permission granted.'); + const token = await getFirebaseMessagingToken(); + await unregisterClientRegistrationToken.mutateAsync({ + id: user.id, + token, + channelType, + }); + console.log('Push notifications unregistered.'); + } + }, [unregisterClientRegistrationToken, user.id]); +} diff --git a/packages/commonwealth/client/scripts/views/pages/discussions/DiscussionsPage.tsx b/packages/commonwealth/client/scripts/views/pages/discussions/DiscussionsPage.tsx index 126aa48bf7b..18c17665678 100644 --- a/packages/commonwealth/client/scripts/views/pages/discussions/DiscussionsPage.tsx +++ b/packages/commonwealth/client/scripts/views/pages/discussions/DiscussionsPage.tsx @@ -57,7 +57,7 @@ const DiscussionsPage = ({ topicName }: DiscussionsPageProps) => { // @ts-expect-error const stageName: string = searchParams.get('stage'); - const weightedVotingEnabled = useFlag('farcasterContest'); + const weightedTopicsEnabled = useFlag('weightedTopics'); const featuredFilter: ThreadFeaturedFilterTypes = searchParams.get( 'featured', @@ -175,7 +175,7 @@ const DiscussionsPage = ({ topicName }: DiscussionsPageProps) => { useManageDocumentTitle('Discussions'); const isTopicWeighted = - weightedVotingEnabled && + weightedTopicsEnabled && topicId && topicObj.weightedVoting === TopicWeightedVoting.ERC20; diff --git a/packages/commonwealth/client/vite.config.ts b/packages/commonwealth/client/vite.config.ts index 2c8ca3a00e8..46846c4e3f4 100644 --- a/packages/commonwealth/client/vite.config.ts +++ b/packages/commonwealth/client/vite.config.ts @@ -35,6 +35,9 @@ export default defineConfig(({ mode }) => { 'process.env.FLAG_NEW_EDITOR': JSON.stringify(env.FLAG_NEW_EDITOR), 'process.env.FLAG_CONTEST': JSON.stringify(env.FLAG_CONTEST), 'process.env.FLAG_CONTEST_DEV': JSON.stringify(env.FLAG_CONTEST_DEV), + 'process.env.FLAG_WEIGHTED_TOPICS': JSON.stringify( + env.FLAG_WEIGHTED_TOPICS, + ), 'process.env.FLAG_KNOCK_PUSH_NOTIFICATIONS_ENABLED': JSON.stringify( env.FLAG_KNOCK_PUSH_NOTIFICATIONS_ENABLED, ),