diff --git a/.env.sample b/.env.sample index e180f1e512..adcff73b88 100644 --- a/.env.sample +++ b/.env.sample @@ -4,3 +4,5 @@ MAINNET_RPC=https://mango.rpcpool.com DEVNET_RPC=https://mango.devnet.rpcpool.com DEFAULT_GOVERNANCE_PROGRAM_ID=GTesTBiEWE32WHXXE2S4XbZvA5CrEc4xs6ZgRe895dP + +NEXT_PUBLIC_API_ENDPOINT=http://localhost:3001/graphql diff --git a/components/App.tsx b/components/App.tsx new file mode 100644 index 0000000000..a2e0dc8b47 --- /dev/null +++ b/components/App.tsx @@ -0,0 +1,221 @@ +import { ThemeProvider } from 'next-themes' +import { WalletIdentityProvider } from '@cardinal/namespaces-components' +import dynamic from 'next/dynamic' +import React, { useEffect } from 'react' +import Head from 'next/head' + +import { GatewayProvider } from '@components/Gateway/GatewayProvider' +import { usePrevious } from '@hooks/usePrevious' +import { useVotingPlugins, vsrPluginsPks } from '@hooks/useVotingPlugins' +import ErrorBoundary from '@components/ErrorBoundary' +import handleGovernanceAssetsStore from '@hooks/handleGovernanceAssetsStore' +import handleRouterHistory from '@hooks/handleRouterHistory' +import NavBar from '@components/NavBar' +import PageBodyContainer from '@components/PageBodyContainer' +import tokenService from '@utils/services/token' +import TransactionLoader from '@components/TransactionLoader' +import useDepositStore from 'VoteStakeRegistry/stores/useDepositStore' +import useGovernanceAssets from '@hooks/useGovernanceAssets' +import useHydrateStore from '@hooks/useHydrateStore' +import useMarketStore from 'Strategies/store/marketStore' +import useMembers from '@components/Members/useMembers' +import useRealm from '@hooks/useRealm' +import useTreasuryAccountStore from 'stores/useTreasuryAccountStore' +import useVotePluginsClientStore from 'stores/useVotePluginsClientStore' +import useWallet from '@hooks/useWallet' +import useWalletStore from 'stores/useWalletStore' +import NftVotingCountingModal from '@components/NftVotingCountingModal' +import { getResourcePathPart } from '@tools/core/resources' + +const Notifications = dynamic(() => import('../components/Notification'), { + ssr: false, +}) + +interface Props { + children: React.ReactNode +} + +export function App(props: Props) { + useHydrateStore() + useWallet() + handleRouterHistory() + useVotingPlugins() + handleGovernanceAssetsStore() + useMembers() + useEffect(() => { + tokenService.fetchSolanaTokenList() + }, []) + const { loadMarket } = useMarketStore() + const { governedTokenAccounts } = useGovernanceAssets() + const possibleNftsAccounts = governedTokenAccounts.filter( + (x) => x.isSol || x.isNft + ) + const { getNfts } = useTreasuryAccountStore() + const { getOwnedDeposits, resetDepositState } = useDepositStore() + const { realm, ownTokenRecord, realmInfo, symbol, config } = useRealm() + const wallet = useWalletStore((s) => s.current) + const connection = useWalletStore((s) => s.connection) + const client = useVotePluginsClientStore((s) => s.state.vsrClient) + const prevStringifyPossibleNftsAccounts = usePrevious( + JSON.stringify(possibleNftsAccounts) + ) + const realmName = realmInfo?.displayName ?? realm?.account?.name + const title = realmName ? `${realmName}` : 'Realms' + + // Note: ?v==${Date.now()} is added to the url to force favicon refresh. + // Without it browsers would cache the last used and won't change it for different realms + // https://stackoverflow.com/questions/2208933/how-do-i-force-a-favicon-refresh + const faviconUrl = + symbol && + `/realms/${getResourcePathPart( + symbol as string + )}/favicon.ico?v=${Date.now()}` + + useEffect(() => { + if (realm?.pubkey) { + loadMarket(connection, connection.cluster) + } + }, [connection.cluster, realm?.pubkey.toBase58()]) + useEffect(() => { + if ( + realm && + config?.account.communityTokenConfig.voterWeightAddin && + vsrPluginsPks.includes( + config.account.communityTokenConfig.voterWeightAddin.toBase58() + ) && + realm.pubkey && + wallet?.connected && + ownTokenRecord && + client + ) { + getOwnedDeposits({ + realmPk: realm!.pubkey, + communityMintPk: realm!.account.communityMint, + walletPk: ownTokenRecord!.account!.governingTokenOwner, + client: client!, + connection: connection.current, + }) + } else if (!wallet?.connected || !ownTokenRecord) { + resetDepositState() + } + }, [ + realm?.pubkey.toBase58(), + ownTokenRecord?.pubkey.toBase58(), + wallet?.connected, + client?.program.programId.toBase58(), + ]) + + useEffect(() => { + if ( + prevStringifyPossibleNftsAccounts !== + JSON.stringify(possibleNftsAccounts) && + realm?.pubkey + ) { + getNfts(possibleNftsAccounts, connection) + } + }, [JSON.stringify(possibleNftsAccounts), realm?.pubkey.toBase58()]) + + return ( +
+ + + {title} + + {faviconUrl ? ( + <> + + + ) : ( + <> + + + + + + + + + + + + + + + )} + + + + + + + + + + {props.children} + + + + +
+ ) +} diff --git a/components/NavBar.tsx b/components/NavBar.tsx index 173126aa62..6f10795d0f 100644 --- a/components/NavBar.tsx +++ b/components/NavBar.tsx @@ -9,7 +9,7 @@ const NavBar = () => { const { fmtUrlWithCluster } = useQueryContext() return ( -
+
diff --git a/components/PageBodyContainer.tsx b/components/PageBodyContainer.tsx index 363c3c7dcc..f4c1256c87 100644 --- a/components/PageBodyContainer.tsx +++ b/components/PageBodyContainer.tsx @@ -12,13 +12,13 @@ const PageBodyContainer = ({ children }) => { isNewRealmsWizard ? '' : 'min-h-[calc(100vh_-_80px)] pb-64' }`} > -
+
-
+
{children}
diff --git a/components/treasuryV2/Details/TokenDetails/Activity.tsx b/components/treasuryV2/Details/TokenDetails/Activity.tsx index ce012f2108..7856335408 100644 --- a/components/treasuryV2/Details/TokenDetails/Activity.tsx +++ b/components/treasuryV2/Details/TokenDetails/Activity.tsx @@ -19,7 +19,7 @@ export default function Activity(props: Props) { const activity = useAccountActivity(props.assets.map((a) => a.address)) const cluster = useWalletStore((s) => s.connection.cluster) - switch (activity.status) { + switch (activity._tag) { case Status.Failed: return (
diff --git a/components/treasuryV2/Details/TokenDetails/Investments.tsx b/components/treasuryV2/Details/TokenDetails/Investments.tsx index 218ccac30a..af3c2be7e7 100644 --- a/components/treasuryV2/Details/TokenDetails/Investments.tsx +++ b/components/treasuryV2/Details/TokenDetails/Investments.tsx @@ -47,7 +47,7 @@ export default function Investments(props: Props) { }) useEffect(() => { - if (investments.status === Status.Ok) { + if (investments._tag === Status.Ok) { setShowAvailableInvestments(!investments.data.activeInvestments.length) } }, [investments]) @@ -58,7 +58,7 @@ export default function Investments(props: Props) { } }, [connection, props]) - switch (investments.status) { + switch (investments._tag) { case Status.Failed: return (
diff --git a/components/treasuryV2/Details/index.tsx b/components/treasuryV2/Details/index.tsx index 9c1c43a2c6..bb24a51c2c 100644 --- a/components/treasuryV2/Details/index.tsx +++ b/components/treasuryV2/Details/index.tsx @@ -30,7 +30,7 @@ interface Props { } const Details = forwardRef((props, ref) => { - switch (props.data.status) { + switch (props.data._tag) { case Status.Failed: return (
diff --git a/components/treasuryV2/TotalValueTitle.tsx b/components/treasuryV2/TotalValueTitle.tsx index 14d1df3bef..61ae01adbe 100644 --- a/components/treasuryV2/TotalValueTitle.tsx +++ b/components/treasuryV2/TotalValueTitle.tsx @@ -17,7 +17,7 @@ interface Props { } export default function TotalValueTitle(props: Props) { - switch (props.data.status) { + switch (props.data._tag) { case Status.Failed: return (
diff --git a/components/treasuryV2/WalletList/index.tsx b/components/treasuryV2/WalletList/index.tsx index 60e7a711c4..7e13d33d29 100644 --- a/components/treasuryV2/WalletList/index.tsx +++ b/components/treasuryV2/WalletList/index.tsx @@ -25,7 +25,7 @@ export default function WalletList(props: Props) { const [expanded, setExpanded] = useState([]) useEffect(() => { - if (props.data.status === Status.Ok) { + if (props.data._tag === Status.Ok) { const expanded = props.data.data.wallets[0] const expandedKey = expanded ? 'address' in expanded @@ -41,9 +41,9 @@ export default function WalletList(props: Props) { } }) } - }, [props.data.status]) + }, [props.data._tag]) - switch (props.data.status) { + switch (props.data._tag) { case Status.Failed: return (
diff --git a/hooks/useAccountActivity.ts b/hooks/useAccountActivity.ts index 669c7fdbf7..9ad250dda4 100644 --- a/hooks/useAccountActivity.ts +++ b/hooks/useAccountActivity.ts @@ -2,7 +2,7 @@ import { useEffect, useState } from 'react' import { Connection, PublicKey, ConfirmedSignatureInfo } from '@solana/web3.js' import useWalletStore from 'stores/useWalletStore' -import { Result, Status, Ok, Failed } from '@utils/uiTypes/Result' +import { Result, Status, Ok, isFailed, isOk } from '@utils/uiTypes/Result' const TEN_MINUTES = 1000 * 60 * 10 @@ -13,16 +13,6 @@ interface CachedData { const cache: Map = new Map() -function isOk(result: Result): result is Ok { - return result.status === Status.Ok -} - -function isFailed( - result: Result -): result is Failed { - return result.status === Status.Failed -} - async function getInfo( address: string, connection: Connection @@ -30,7 +20,7 @@ async function getInfo( const cachedValue = cache.get(address) if (cachedValue && cachedValue.time + TEN_MINUTES > Date.now()) { - return { status: Status.Ok, data: cachedValue.values } + return { _tag: Status.Ok, data: cachedValue.values } } return connection @@ -43,17 +33,17 @@ async function getInfo( ) .then((values) => { cache.set(address, { values, time: Date.now() }) - return { status: Status.Ok, data: values } as Ok + return { _tag: Status.Ok, data: values } as Ok }) .catch((e) => ({ - status: Status.Failed, + _tag: Status.Failed, error: e instanceof Error ? e : new Error(e), })) } export default function useAccountActivity(accountAddress: string | string[]) { const [result, setResult] = useState>({ - status: Status.Pending, + _tag: Status.Pending, }) const connection = useWalletStore((s) => s.connection.current) const addresses = Array.isArray(accountAddress) @@ -61,7 +51,7 @@ export default function useAccountActivity(accountAddress: string | string[]) { : [accountAddress] useEffect(() => { - setResult({ status: Status.Pending }) + setResult({ _tag: Status.Pending }) Promise.all(addresses.map((address) => getInfo(address, connection))).then( (results) => { @@ -76,17 +66,17 @@ export default function useAccountActivity(accountAddress: string | string[]) { .slice(0, 10) setResult({ - status: Status.Ok, + _tag: Status.Ok, data: values, }) } else if (fails.length) { setResult({ - status: Status.Failed, + _tag: Status.Failed, error: fails[0].error, }) } else { setResult({ - status: Status.Failed, + _tag: Status.Failed, error: new Error('Unknown Error'), }) } diff --git a/hooks/useAccountInvestments/index.ts b/hooks/useAccountInvestments/index.ts index 90cd767c5b..b0ff3f557e 100644 --- a/hooks/useAccountInvestments/index.ts +++ b/hooks/useAccountInvestments/index.ts @@ -42,7 +42,7 @@ interface Data { export function useAccountInvestments(args: Args) { const [result, setResult] = useState>({ - status: Status.Pending, + _tag: Status.Pending, }) const [calledGetStrategies, setCalledGetStrategies] = useState(false) @@ -84,7 +84,7 @@ export function useAccountInvestments(args: Args) { } if (strategyMintAddress && calledGetStrategies && !strategiesLoading) { - setResult({ status: Status.Pending }) + setResult({ _tag: Status.Pending }) const fetchMangoAccounts = governanceAddress ? tryGetMangoAccountsForOwner( @@ -116,7 +116,7 @@ export function useAccountInvestments(args: Args) { }) .then((activeInvestments) => { const result = { - status: Status.Ok, + _tag: Status.Ok, data: { activeInvestments: activeInvestments.filter( (i) => !!i.investedAmount @@ -142,7 +142,7 @@ export function useAccountInvestments(args: Args) { }) .catch((e) => setResult({ - status: Status.Failed, + _tag: Status.Failed, error: e instanceof Error ? e : new Error(e), }) ) diff --git a/hooks/useTreasuryInfo/index.tsx b/hooks/useTreasuryInfo/index.tsx index 8b1fd33945..d1fb52a1e1 100644 --- a/hooks/useTreasuryInfo/index.tsx +++ b/hooks/useTreasuryInfo/index.tsx @@ -100,12 +100,12 @@ export default function useTreasuryInfo(): Result { if (!realmInfo || loadingGovernedAccounts || nftsLoading || buildingWallets) { return { - status: Status.Pending, + _tag: Status.Pending, } } return { - status: Status.Ok, + _tag: Status.Ok, data: { wallets, auxiliaryWallets: auxWallets, diff --git a/hub/.eslintrc.json b/hub/.eslintrc.json new file mode 100644 index 0000000000..6683913c25 --- /dev/null +++ b/hub/.eslintrc.json @@ -0,0 +1,44 @@ +{ + "parser": "@typescript-eslint/parser", + "parserOptions": { + "project": "tsconfig.json", + "tsconfigRootDir": ".", + "sourceType": "module" + }, + "plugins": ["@typescript-eslint/eslint-plugin", "import"], + "extends": [ + "plugin:@typescript-eslint/recommended", + "plugin:prettier/recommended" + ], + "root": true, + "env": { + "node": true, + "jest": true + }, + "ignorePatterns": [".eslintrc.js", "migrations"], + "rules": { + "@typescript-eslint/interface-name-prefix": "off", + "@typescript-eslint/explicit-function-return-type": "off", + "@typescript-eslint/explicit-module-boundary-types": "off", + "@typescript-eslint/ban-ts-comment": "off", + "@typescript-eslint/no-explicit-any": "off", + "@typescript-eslint/no-empty-interface": "off", + "import/order": [ + "error", + { + "newlines-between": "always-and-inside-groups", + "pathGroupsExcludedImportTypes": ["builtin"], + "pathGroups": [ + { + "pattern": "@hub/**/**", + "group": "parent" + } + ], + "alphabetize": { + "order": "asc", + "caseInsensitive": true + } + } + ] + } +} diff --git a/hub/.prettierrc b/hub/.prettierrc new file mode 100644 index 0000000000..c29a1e1f0e --- /dev/null +++ b/hub/.prettierrc @@ -0,0 +1,6 @@ +{ + "semi": true, + "singleQuote": true, + "trailingComma": "all", + "quoteProps": "consistent" +} diff --git a/hub/App.tsx b/hub/App.tsx new file mode 100644 index 0000000000..caa67b15e0 --- /dev/null +++ b/hub/App.tsx @@ -0,0 +1,95 @@ +import Head from 'next/head'; +import React from 'react'; + +import { GlobalHeader } from '@hub/components/GlobalHeader'; +import { RootProvider } from '@hub/providers/Root'; + +interface Props { + children?: React.ReactNode; +} + +export function App(props: Props) { + return ( + + + + + + + + + + + + + + + + + + + {props.children} + + ); +} diff --git a/hub/components/AuthorAvatar/index.tsx b/hub/components/AuthorAvatar/index.tsx new file mode 100644 index 0000000000..8a30d3c3a6 --- /dev/null +++ b/hub/components/AuthorAvatar/index.tsx @@ -0,0 +1,90 @@ +import FaceSatisfiedIcon from '@carbon/icons-react/lib/FaceSatisfied'; +import type { PublicKey } from '@solana/web3.js'; + +import { FeedItemAuthor } from '../Home/Feed/gql'; +import cx from '@hub/lib/cx'; + +const POSSIBLE_COLORS = [ + 'bg-red-400', + 'bg-orange-400', + 'bg-amber-400', + 'bg-yellow-400', + 'bg-lime-400', + 'bg-green-400', + 'bg-emerald-400', + 'bg-cyan-400', + 'bg-sky-400', + 'bg-blue-400', + 'bg-indigo-400', + 'bg-violet-400', + 'bg-purple-400', + 'bg-fuchsia-400', + 'bg-pink-400', + 'bg-rose-400', +]; + +const computedColors = new Map(); + +function pickDefaultBg(publicKey: PublicKey) { + const pk = publicKey.toBase58(); + + if (computedColors.has(pk)) { + return computedColors.get(pk); + } + + const num = pk.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0); + const index = num % POSSIBLE_COLORS.length; + + const color = POSSIBLE_COLORS[index]; + + if (typeof window !== 'undefined') { + computedColors.set(pk, color); + } + + return color; +} + +interface Props { + className?: string; + author?: FeedItemAuthor | null; +} + +export function AuthorAvatar(props: Props) { + if (props.author?.twitterInfo?.avatarUrl) { + return ( + + ); + } else if (props.author) { + const bgColor = pickDefaultBg(props.author.publicKey); + const text = props.author.publicKey.toBase58().slice(0, 2); + + return ( +
+ {text} +
+ ); + } else { + return ( + + ); + } +} diff --git a/hub/components/FeedItem/Back/index.tsx b/hub/components/FeedItem/Back/index.tsx new file mode 100644 index 0000000000..dfdfc2c439 --- /dev/null +++ b/hub/components/FeedItem/Back/index.tsx @@ -0,0 +1,63 @@ +import ArrowLeftIcon from '@carbon/icons-react/lib/ArrowLeft'; +import { useRouter } from 'next/router'; + +import cx from '@hub/lib/cx'; + +interface Props { + className?: string; +} + +export function Content(props: Props) { + const router = useRouter(); + + return ( + + ); +} + +export function Error(props: Props) { + return ( +
+   +
+ ); +} + +export function Loading(props: Props) { + return ( +
+   +
+ ); +} diff --git a/hub/components/FeedItem/Footer/index.tsx b/hub/components/FeedItem/Footer/index.tsx new file mode 100644 index 0000000000..e3b8c2cb55 --- /dev/null +++ b/hub/components/FeedItem/Footer/index.tsx @@ -0,0 +1,42 @@ +import type { PublicKey } from '@solana/web3.js'; +import type { BigNumber } from 'bignumber.js'; + +import { Controls } from '@hub/components/Home/Feed/FeedItem/Controls'; +import cx from '@hub/lib/cx'; +import { FeedItemVoteType } from '@hub/types/FeedItemVoteType'; + +interface BaseProps { + className?: string; +} + +interface Props extends BaseProps { + feedItemId: string; + realm: PublicKey; + score: number; + totalProposalVotes?: BigNumber | null; + userVote?: FeedItemVoteType | null; +} + +export function Content(props: Props) { + return ; +} + +export function Error(props: BaseProps) { + return ( +
+
+
+ ); +} + +export function Loading(props: BaseProps) { + return ( +
+
+
+ ); +} diff --git a/hub/components/FeedItem/Header/index.tsx b/hub/components/FeedItem/Header/index.tsx new file mode 100644 index 0000000000..6ca15c8495 --- /dev/null +++ b/hub/components/FeedItem/Header/index.tsx @@ -0,0 +1,68 @@ +import { differenceInMinutes, formatDistanceToNowStrict } from 'date-fns'; + +import { FeedItemAuthor } from '../../Home/Feed/gql'; +import { AuthorAvatar } from '@hub/components/AuthorAvatar'; +import { abbreviateAddress } from '@hub/lib/abbreviateAddress'; +import cx from '@hub/lib/cx'; + +const EDIT_GRACE_PERIOD = 3; // minutes + +interface BaseProps { + className?: string; +} + +interface Props extends BaseProps { + author?: FeedItemAuthor | null; + created: number; + updated: number; +} + +export function Content(props: Props) { + const author = props.author; + + const authorName = author + ? author.twitterInfo?.handle || abbreviateAddress(author.publicKey) + : 'unknown author'; + + const isEdited = + differenceInMinutes(props.updated, props.created) > EDIT_GRACE_PERIOD; + + return ( +
+ +
{authorName}
+
+ {formatDistanceToNowStrict(props.created)} ago + {isEdited ? ' *' : ''} +
+
+ ); +} + +export function Error(props: BaseProps) { + return ( +
+
+
+   +
+
+   +
+
+ ); +} + +export function Loading(props: BaseProps) { + return ( +
+
+
+   +
+
+   +
+
+ ); +} diff --git a/hub/components/FeedItem/Title/index.tsx b/hub/components/FeedItem/Title/index.tsx new file mode 100644 index 0000000000..684bd80a44 --- /dev/null +++ b/hub/components/FeedItem/Title/index.tsx @@ -0,0 +1,52 @@ +import cx from '@hub/lib/cx'; + +interface BaseProps { + className?: string; +} + +interface Props extends BaseProps { + title: string; +} + +export function Content(props: Props) { + return ( +

+ {props.title} +

+ ); +} + +export function Error(props: BaseProps) { + return ( +
+   +
+ ); +} + +export function Loading(props: BaseProps) { + return ( +
+   +
+ ); +} diff --git a/hub/components/FeedItem/gql.ts b/hub/components/FeedItem/gql.ts new file mode 100644 index 0000000000..5dfadba52f --- /dev/null +++ b/hub/components/FeedItem/gql.ts @@ -0,0 +1,47 @@ +import * as IT from 'io-ts'; +import { gql } from 'urql'; + +import { feedItemParts, FeedItem } from '../Home/Feed/gql'; +import { PublicKey } from '@hub/types/decoders/PublicKey'; + +export const getFeedItem = gql` + ${feedItemParts} + + query getFeedItem($realm: PublicKey!, $feedItemId: RealmFeedItemID!) { + feedItem(id: $feedItemId, realm: $realm) { + ...FeedItemParts + } + } +`; + +export const getFeedItemResp = IT.type({ + feedItem: FeedItem, +}); + +export const getRealm = gql` + query getRealm($realm: PublicKey!) { + realm(publicKey: $realm) { + bannerImageUrl + iconUrl + membersCount + name + publicKey + symbol + twitterHandle + websiteUrl + } + } +`; + +export const getRealmResp = IT.type({ + realm: IT.type({ + bannerImageUrl: IT.union([IT.null, IT.string]), + iconUrl: IT.union([IT.null, IT.string]), + membersCount: IT.number, + name: IT.string, + publicKey: PublicKey, + symbol: IT.union([IT.null, IT.string]), + twitterHandle: IT.union([IT.null, IT.string]), + websiteUrl: IT.union([IT.null, IT.string]), + }), +}); diff --git a/hub/components/FeedItem/index.tsx b/hub/components/FeedItem/index.tsx new file mode 100644 index 0000000000..7cf8d4bd95 --- /dev/null +++ b/hub/components/FeedItem/index.tsx @@ -0,0 +1,160 @@ +import * as Separator from '@radix-ui/react-separator'; +import type { PublicKey } from '@solana/web3.js'; +import { pipe } from 'fp-ts/function'; + +import * as Sidebar from '../Home/Sidebar'; +import { HomeLayout } from '@hub/components/HomeLayout'; +import { RichTextDocumentDisplay } from '@hub/components/RichTextDocumentDisplay'; +import { useQuery } from '@hub/hooks/useQuery'; +import { getDefaultBannerUrl } from '@hub/lib/getDefaultBannerUrl'; +import * as RE from '@hub/types/Result'; + +import * as Back from './Back'; +import * as Footer from './Footer'; +import * as gql from './gql'; +import * as Header from './Header'; +import * as Title from './Title'; + +interface Props { + className?: string; + feedItemId: string; + realm: PublicKey; + realmUrlId: string; +} + +export function FeedItem(props: Props) { + const [feedItemResult] = useQuery(gql.getFeedItemResp, { + query: gql.getFeedItem, + variables: { + realm: props.realm, + feedItemId: props.feedItemId, + }, + }); + const [realmResult] = useQuery(gql.getRealmResp, { + query: gql.getRealm, + variables: { realm: props.realm }, + }); + + return ( +
+ {pipe( + realmResult, + RE.match( + () => ( + } + content={() => ( +
+ + + + +
+ + +
+ )} + /> + ), + () => ( + } + content={() => ( +
+ + + + +
+ + +
+ )} + /> + ), + ({ realm }) => { + return ( + ( + + )} + content={() => + pipe( + feedItemResult, + RE.match( + () => ( +
+ + + + +
+ + +
+ ), + () => ( +
+ + + + +
+ + +
+ ), + ({ feedItem }) => ( +
+ + + + + + + +
+ ), + ), + ) + } + /> + ); + }, + ), + )} +
+ ); +} diff --git a/hub/components/GlobalHeader/Logo.tsx b/hub/components/GlobalHeader/Logo.tsx new file mode 100644 index 0000000000..bcbfdee7b2 --- /dev/null +++ b/hub/components/GlobalHeader/Logo.tsx @@ -0,0 +1,20 @@ +import * as NavigationMenu from '@radix-ui/react-navigation-menu'; +import Link from 'next/link'; + +import { RealmsLogo } from '@hub/components/branding/RealmsLogo'; + +interface Props { + className?: string; +} + +export function Logo(props: Props) { + return ( + + + + + + + + ); +} diff --git a/hub/components/GlobalHeader/UserDropdown/Connect.tsx b/hub/components/GlobalHeader/UserDropdown/Connect.tsx new file mode 100644 index 0000000000..32762a8d43 --- /dev/null +++ b/hub/components/GlobalHeader/UserDropdown/Connect.tsx @@ -0,0 +1,111 @@ +import * as NavigationMenu from '@radix-ui/react-navigation-menu'; +import * as IT from 'io-ts'; +import { gql } from 'urql'; + +import { SolanaLogo } from '@hub/components/branding/SolanaLogo'; +import { useJWT } from '@hub/hooks/useJWT'; +import { useMutation } from '@hub/hooks/useMutation'; +import { useToast, ToastType } from '@hub/hooks/useToast'; +import { useWallet } from '@hub/hooks/useWallet'; +import cx from '@hub/lib/cx'; +import * as sig from '@hub/lib/signature'; +import * as RE from '@hub/types/Result'; + +const getClaim = gql` + mutation getClaim($publicKey: PublicKey!) { + createAuthenticationClaim(publicKey: $publicKey) { + claim + } + } +`; + +const getClaimResp = IT.type({ + createAuthenticationClaim: IT.type({ + claim: IT.string, + }), +}); + +const getToken = gql` + mutation getToken($claim: String!, $signature: Signature!) { + createAuthenticationToken(claim: $claim, signature: $signature) + } +`; + +const getTokenResp = IT.type({ + createAuthenticationToken: IT.string, +}); + +interface Props { + className?: string; + onConnected?(): void; +} + +export function Connect(props: Props) { + const { connect, signMessage } = useWallet(); + const [, createClaim] = useMutation(getClaimResp, getClaim); + const [, createToken] = useMutation(getTokenResp, getToken); + const [, setJwt] = useJWT(); + const { publish } = useToast(); + + return ( + + + + ); +} diff --git a/hub/components/GlobalHeader/UserDropdown/DropdownButton.tsx b/hub/components/GlobalHeader/UserDropdown/DropdownButton.tsx new file mode 100644 index 0000000000..311c1c9352 --- /dev/null +++ b/hub/components/GlobalHeader/UserDropdown/DropdownButton.tsx @@ -0,0 +1,31 @@ +import * as NavigationMenu from '@radix-ui/react-navigation-menu'; + +import cx from '@hub/lib/cx'; + +type Props = React.ButtonHTMLAttributes; + +export function DropdownButton(props: Props) { + return ( + +
, + ); + } + + return cloneElement(tag, { + children, + className: cx(tag.props.className, props.className), + }); +} diff --git a/hub/components/RichTextDocumentDisplay/ImageNode/index.tsx b/hub/components/RichTextDocumentDisplay/ImageNode/index.tsx new file mode 100644 index 0000000000..f31b0713b7 --- /dev/null +++ b/hub/components/RichTextDocumentDisplay/ImageNode/index.tsx @@ -0,0 +1,14 @@ +import { ImageNode as ImageNodeModel } from '@hub/types/RichTextDocument'; + +interface Props { + className?: string; + image: ImageNodeModel; + isClipped?: boolean; + isLast?: boolean; + showExpand?: boolean; + onExpand?(): void; +} + +export function ImageNode(props: Props) { + return
; +} diff --git a/hub/components/RichTextDocumentDisplay/index.tsx b/hub/components/RichTextDocumentDisplay/index.tsx new file mode 100644 index 0000000000..1e14dd1212 --- /dev/null +++ b/hub/components/RichTextDocumentDisplay/index.tsx @@ -0,0 +1,46 @@ +import cx from '@hub/lib/cx'; +import { BlockNodeType, RichTextDocument } from '@hub/types/RichTextDocument'; + +import { BlockNode } from './BlockNode'; +import { ImageNode } from './ImageNode'; + +interface Props { + className?: string; + document: RichTextDocument; + isClipped?: boolean; + showExpand?: boolean; + onExpand?(): void; +} + +export function RichTextDocumentDisplay(props: Props) { + return ( +
+ {props.document.content.map((node, i) => { + switch (node.t) { + case BlockNodeType.Block: + return ( + + ); + case BlockNodeType.Image: + return ( + + ); + } + })} +
+ ); +} diff --git a/hub/components/branding/RealmCircle.tsx b/hub/components/branding/RealmCircle.tsx new file mode 100644 index 0000000000..6810d86c9b --- /dev/null +++ b/hub/components/branding/RealmCircle.tsx @@ -0,0 +1,36 @@ +import React from 'react'; + +type Props = React.SVGAttributes; + +export function RealmCircle(props: Props) { + return ( + + + + + + + + + + + ); +} diff --git a/hub/components/branding/RealmsLogo.tsx b/hub/components/branding/RealmsLogo.tsx new file mode 100644 index 0000000000..02585e8ed3 --- /dev/null +++ b/hub/components/branding/RealmsLogo.tsx @@ -0,0 +1,57 @@ +import React from 'react'; + +type Props = React.SVGAttributes; + +export function RealmsLogo(props: Props) { + return ( + + + + + + + + + + + + + + + + + ); +} diff --git a/hub/components/branding/SolanaLogo.tsx b/hub/components/branding/SolanaLogo.tsx new file mode 100644 index 0000000000..76fdd2a5ed --- /dev/null +++ b/hub/components/branding/SolanaLogo.tsx @@ -0,0 +1,36 @@ +import React from 'react'; + +type Props = React.SVGAttributes; + +export function SolanaLogo(props: Props) { + return ( + + + + + + + + + + + + + + ); +} diff --git a/hub/components/branding/WelcomeHand.tsx b/hub/components/branding/WelcomeHand.tsx new file mode 100644 index 0000000000..c976fe7d7e --- /dev/null +++ b/hub/components/branding/WelcomeHand.tsx @@ -0,0 +1,63 @@ +import React from 'react'; + +type Props = React.SVGAttributes; + +export function WelcomeHand(props: Props) { + return ( + + + + + + + + + + + + + + + ); +} diff --git a/hub/components/controls/Button/Primary.tsx b/hub/components/controls/Button/Primary.tsx new file mode 100644 index 0000000000..6eba72220d --- /dev/null +++ b/hub/components/controls/Button/Primary.tsx @@ -0,0 +1,66 @@ +import { forwardRef } from 'react'; + +import { LoadingDots } from '@hub/components/LoadingDots'; +import cx from '@hub/lib/cx'; + +interface Props extends React.ButtonHTMLAttributes { + pending?: boolean; +} + +export const Primary = forwardRef(function Primary( + props, + ref, +) { + const { pending, ...rest } = props; + + return ( + + ); +}); diff --git a/hub/components/controls/Button/Secondary.tsx b/hub/components/controls/Button/Secondary.tsx new file mode 100644 index 0000000000..318c4309c0 --- /dev/null +++ b/hub/components/controls/Button/Secondary.tsx @@ -0,0 +1,66 @@ +import { forwardRef } from 'react'; + +import { LoadingDots } from '@hub/components/LoadingDots'; +import cx from '@hub/lib/cx'; + +interface Props extends React.ButtonHTMLAttributes { + pending?: boolean; +} + +export const Secondary = forwardRef( + function Secondary(props, ref) { + const { pending, ...rest } = props; + + return ( + + ); + }, +); diff --git a/hub/components/controls/Button/index.tsx b/hub/components/controls/Button/index.tsx new file mode 100644 index 0000000000..54cbbd6a78 --- /dev/null +++ b/hub/components/controls/Button/index.tsx @@ -0,0 +1,2 @@ +export { Primary } from './Primary'; +export { Secondary } from './Secondary'; diff --git a/hub/components/controls/Dialog/index.tsx b/hub/components/controls/Dialog/index.tsx new file mode 100644 index 0000000000..8f52b1bc75 --- /dev/null +++ b/hub/components/controls/Dialog/index.tsx @@ -0,0 +1,99 @@ +import CloseIcon from '@carbon/icons-react/lib/Close'; +import * as _Dialog from '@radix-ui/react-dialog'; +import { forwardRef } from 'react'; + +import cx from '@hub/lib/cx'; + +export const Portal = _Dialog.Portal; +export const Root = _Dialog.Root; +export const Trigger = _Dialog.Trigger; + +export const Close = forwardRef( + function Close(props, ref) { + return ( + <_Dialog.Close + {...props} + className={cx('absolute', 'top-4', 'right-4', props.className)} + ref={ref} + > + + + ); + }, +); + +export const Content = forwardRef( + function Content(props, ref) { + return ( + <_Dialog.Content + {...props} + className={cx( + 'bg-white', + 'drop-shadow-xl', + 'relative', + 'rounded', + props.className, + )} + ref={ref} + /> + ); + }, +); + +export const Description = forwardRef< + HTMLParagraphElement, + _Dialog.DialogDescriptionProps +>(function Description(props, ref) { + return ( + <_Dialog.Description + {...props} + className={cx('px-4', 'overflow-y-auto', props.className)} + ref={ref} + /> + ); +}); + +export const Overlay = forwardRef( + function Overlay(props, ref) { + return ( + <_Dialog.Overlay + {...props} + className={cx( + 'backdrop-blur-sm', + 'bg-black/10', + 'bottom-0', + 'fixed', + 'flex', + 'items-center', + 'justify-center', + 'left-0', + 'right-0', + 'top-0', + 'z-40', + props.className, + )} + ref={ref} + /> + ); + }, +); + +export const Title = forwardRef( + function Title(props, ref) { + return ( + <_Dialog.Title + {...props} + className={cx( + 'font-normal', + 'mb-0', + 'mt-3', + 'text-base', + 'text-center', + 'text-neutral-900', + props.className, + )} + ref={ref} + /> + ); + }, +); diff --git a/hub/components/controls/RichTextEditor/index.css b/hub/components/controls/RichTextEditor/index.css new file mode 100644 index 0000000000..e7e879b00e --- /dev/null +++ b/hub/components/controls/RichTextEditor/index.css @@ -0,0 +1,13 @@ +.public-DraftEditorPlaceholder-inner { + color: #d4d4d4; + font-size: 18px; + font-weight: 400px; + line-height: 28px; +} + +.public-DraftEditor-content { + color: #18181b; + font-size: 18px; + font-weight: 400px; + line-height: 28px; +} diff --git a/hub/components/controls/RichTextEditor/index.tsx b/hub/components/controls/RichTextEditor/index.tsx new file mode 100644 index 0000000000..6b1219b3da --- /dev/null +++ b/hub/components/controls/RichTextEditor/index.tsx @@ -0,0 +1,71 @@ +// @ts-ignore +import type { Editor, EditorState } from 'draft-js'; +import React, { forwardRef, useEffect, useState } from 'react'; + +import type { fromEditorState, toEditorState } from '@hub/lib/richText'; +import { RichTextDocument } from '@hub/types/RichTextDocument'; + +interface Utilities { + fromEditorState: typeof fromEditorState; + toEditorState: typeof toEditorState; +} + +interface Props { + className?: string; + defaultDocument?: RichTextDocument; + placeholder?: string; + onBlur?(): void; + onChange?(document: RichTextDocument): void; + onClick?(): void; + onFocus?(): void; +} + +export const RichTextEditor = forwardRef(function RichTextEditor( + props: Props, + ref, +) { + const [EditorComponent, setEditor] = useState(null); + const [state, setState] = useState(null); + const [utilities, setUtilities] = useState(null); + + useEffect(() => { + if (typeof window !== 'undefined') { + Promise.all([ + // @ts-ignore + import('draft-js'), + import('@hub/lib/richText'), + import('./styles'), + ]).then(([{ Editor, EditorState }, utilities]) => { + if (props.defaultDocument) { + setState(utilities.toEditorState(props.defaultDocument)); + } else { + setState(EditorState.createEmpty()); + } + setEditor(() => Editor); + setUtilities({ + fromEditorState: utilities.fromEditorState, + toEditorState: utilities.toEditorState, + }); + }); + } + }, [setEditor]); + + return ( +
+ {EditorComponent && state && utilities && ( + { + setState(state); + props.onChange?.(utilities.fromEditorState(state)); + }} + onFocus={props.onFocus} + /> + )} +
+ ); +}); diff --git a/hub/components/controls/RichTextEditor/styles.ts b/hub/components/controls/RichTextEditor/styles.ts new file mode 100644 index 0000000000..2eae11746b --- /dev/null +++ b/hub/components/controls/RichTextEditor/styles.ts @@ -0,0 +1 @@ +import 'draft-js/dist/Draft.css'; diff --git a/hub/components/controls/Select/index.tsx b/hub/components/controls/Select/index.tsx new file mode 100644 index 0000000000..7d8a5fac4e --- /dev/null +++ b/hub/components/controls/Select/index.tsx @@ -0,0 +1,130 @@ +import Checkmark from '@carbon/icons-react/lib/Checkmark'; +import ChevronDown from '@carbon/icons-react/lib/ChevronDown'; +import * as _Select from '@radix-ui/react-select'; +import { ForwardedRef, forwardRef, ForwardRefRenderFunction } from 'react'; + +import cx from '@hub/lib/cx'; + +interface Choice { + key: string; + label: string; + value: T; +} + +interface Props { + className?: string; + choices: Choice[]; + selected: string; + onChange?(item: Choice): void; +} + +interface Select { + (props: Props, ref: HTMLButtonElement): ReturnType< + ForwardRefRenderFunction, HTMLButtonElement> + >; +} + +export const Select: Select = forwardRef(function Select( + props: Props, + ref: ForwardedRef, +) { + const selectedChoice = props.choices.find( + (choice) => choice.key === props.selected, + ); + + if (!selectedChoice) { + throw new Error('Invalid selected choice'); + } + + return ( + <_Select.Root + value={props.selected} + onValueChange={(key) => { + const item = props.choices.find((choice) => choice.key === key); + + if (item && props.onChange) { + props.onChange(item); + } + }} + > + <_Select.Trigger + className={cx( + 'flex', + 'group', + 'h-10', + 'items-center', + 'justify-end', + 'outline-none', + 'px-3', + 'space-x-2', + 'tracking-normal', + props.className, + )} + ref={ref} + > +
+ <_Select.Value>{selectedChoice.label} +
+ <_Select.Icon> + + + + <_Select.Portal> + <_Select.Content + className={cx( + props.className, + 'bg-white', + 'rounded', + 'overflow-hidden', + 'tracking-normal', + )} + > + <_Select.Viewport> + {props.choices.map((choice) => ( + <_Select.Item + value={choice.key} + className={cx( + 'cursor-pointer', + 'flex', + 'h-10', + 'items-center', + 'justify-end', + 'outline-none', + 'pl-3', + 'pr-8', + 'relative', + 'text-neutral-900', + 'hover:bg-neutral-200', + 'focus:bg-neutral-200', + )} + key={choice.key} + > +
+ <_Select.ItemText>{choice.label} +
+ <_Select.ItemIndicator> + + + + ))} + + + + + ); +}); diff --git a/hub/hooks/useCluster.ts b/hub/hooks/useCluster.ts new file mode 100644 index 0000000000..c2f5170c6c --- /dev/null +++ b/hub/hooks/useCluster.ts @@ -0,0 +1,10 @@ +import { useContext } from 'react'; + +import { ClusterType, context } from '@hub/providers/Cluster'; + +export function useCluster() { + const value = useContext(context); + return [value.cluster, value.setType, value.type] as const; +} + +export { ClusterType }; diff --git a/hub/hooks/useJWT.ts b/hub/hooks/useJWT.ts new file mode 100644 index 0000000000..db9f46de6d --- /dev/null +++ b/hub/hooks/useJWT.ts @@ -0,0 +1,8 @@ +import { useContext } from 'react'; + +import { context } from '@hub/providers/JWT'; + +export function useJWT() { + const value = useContext(context); + return [value.jwt, value.setJwt] as const; +} diff --git a/hub/hooks/useMutation.ts b/hub/hooks/useMutation.ts new file mode 100644 index 0000000000..ee65bd0bb0 --- /dev/null +++ b/hub/hooks/useMutation.ts @@ -0,0 +1,106 @@ +import { left, match } from 'fp-ts/Either'; +import { pipe } from 'fp-ts/function'; +import { DocumentNode } from 'graphql'; +import { Type, TypeOf } from 'io-ts'; +import { PathReporter } from 'io-ts/PathReporter'; +import { + useMutation as _useMutation, + TypedDocumentNode, + OperationContext, + CombinedError, + UseMutationState, +} from 'urql'; + +import * as RE from '@hub/types/Result'; + +function convertResponse( + type: Type, + resp: UseMutationState, +): RE.Result>, CombinedError> { + if (resp.fetching) { + return RE.pending(); + } else if (resp.error) { + return RE.failed(resp.error); + } else if (!resp.data) { + return RE.failed( + new CombinedError({ + graphQLErrors: ['Could not fetch data'], + }), + ); + } else { + return pipe( + resp.data, + type.decode, + match( + (error) => + RE.failed( + new CombinedError({ + graphQLErrors: PathReporter.report(left(error)), + }), + ), + (result) => + resp.stale + ? (RE.stale(result) as RE.Result) + : (RE.ok(result) as RE.Result), + ), + ); + } +} + +export function useMutation( + responseType: Type, + query: DocumentNode | TypedDocumentNode | string, +): [ + RE.Result>, CombinedError>, + ( + variables?: Variables, + context?: Partial, + ) => Promise< + Exclude< + RE.Result>, CombinedError>, + { _tag: RE.Status.Pending } + > + >, +] { + const [resp, run] = _useMutation(query); + const result = convertResponse(responseType, resp); + + return [ + result, + (variables?: Variables, context?: Partial) => + run(variables || ({} as Variables), context).then((resp) => { + if (resp.error) { + return RE.failed(resp.error); + } else if (!resp.data) { + return RE.failed( + new CombinedError({ + graphQLErrors: ['Could not fetch data'], + }), + ); + } else { + return pipe( + resp.data, + responseType.decode, + match( + (error) => + RE.failed( + new CombinedError({ + graphQLErrors: PathReporter.report(left(error)), + }), + ), + (result) => + resp.stale + ? (RE.stale(result) as Exclude< + RE.Result>, CombinedError>, + { _tag: RE.Status.Pending } + >) + : (RE.ok(result) as Exclude< + RE.Result>, CombinedError>, + { _tag: RE.Status.Pending } + >), + ), + ); + } + }), + ]; +} diff --git a/hub/hooks/usePromise.ts b/hub/hooks/usePromise.ts new file mode 100644 index 0000000000..07b6179ee0 --- /dev/null +++ b/hub/hooks/usePromise.ts @@ -0,0 +1,12 @@ +import { useRef } from 'react'; + +export function usePromise() { + const resolver = useRef<((value: T) => void) | null>(null); + const promise = useRef>( + new Promise((resolve) => { + resolver.current = (value: T) => resolve(value); + }), + ); + + return [promise.current, (value: T) => resolver.current?.(value)] as const; +} diff --git a/hub/hooks/useQuery.ts b/hub/hooks/useQuery.ts new file mode 100644 index 0000000000..9f280a7f53 --- /dev/null +++ b/hub/hooks/useQuery.ts @@ -0,0 +1,56 @@ +import { left, match } from 'fp-ts/Either'; +import { pipe } from 'fp-ts/function'; +import { Type, TypeOf } from 'io-ts'; +import { PathReporter } from 'io-ts/PathReporter'; +import { + useQuery as _useQuery, + UseQueryArgs, + OperationContext, + CombinedError, +} from 'urql'; + +import * as RE from '@hub/types/Result'; + +export function useQuery( + responseType: Type, + args: UseQueryArgs>>, +): [ + RE.Result>, CombinedError>, + (opts?: Partial | undefined) => void, +] { + const [resp, fn] = _useQuery(args); + + if (resp.fetching) { + return [RE.pending(), fn]; + } else if (resp.error) { + return [RE.failed(resp.error), fn]; + } else if (!resp.data) { + return [ + RE.failed( + new CombinedError({ + graphQLErrors: ['Could not fetch data'], + }), + ), + fn, + ]; + } else { + return pipe( + resp.data, + responseType.decode, + match( + (error) => [ + RE.failed( + new CombinedError({ + graphQLErrors: PathReporter.report(left(error)), + }), + ), + fn, + ], + (result) => + resp.stale + ? [RE.stale(result) as RE.Result, fn] + : [RE.ok(result) as RE.Result, fn], + ), + ); + } +} diff --git a/hub/hooks/useToast.ts b/hub/hooks/useToast.ts new file mode 100644 index 0000000000..821d4cb27c --- /dev/null +++ b/hub/hooks/useToast.ts @@ -0,0 +1,10 @@ +import { useContext } from 'react'; + +import { context, ToastType } from '@hub/providers/Toast'; + +export function useToast() { + const value = useContext(context); + return { publish: value.publish }; +} + +export { ToastType }; diff --git a/hub/hooks/useUserPrefs.ts b/hub/hooks/useUserPrefs.ts new file mode 100644 index 0000000000..002580aa88 --- /dev/null +++ b/hub/hooks/useUserPrefs.ts @@ -0,0 +1,7 @@ +import { useContext } from 'react'; + +import { context } from '@hub/providers/UserPrefs'; + +export function useUserPrefs() { + return useContext(context); +} diff --git a/hub/hooks/useWallet.ts b/hub/hooks/useWallet.ts new file mode 100644 index 0000000000..ef943f6cc9 --- /dev/null +++ b/hub/hooks/useWallet.ts @@ -0,0 +1,10 @@ +import { useContext } from 'react'; + +import { context } from '@hub/providers/Wallet'; + +export function useWallet() { + const { connect, publicKey, signMessage, signTransation } = useContext( + context, + ); + return { connect, publicKey, signMessage, signTransation }; +} diff --git a/hub/hooks/useWalletSelector.ts b/hub/hooks/useWalletSelector.ts new file mode 100644 index 0000000000..94f472476a --- /dev/null +++ b/hub/hooks/useWalletSelector.ts @@ -0,0 +1,7 @@ +import { useContext } from 'react'; + +import { context } from '@hub/providers/WalletSelector'; + +export function useWalletSelector() { + return useContext(context); +} diff --git a/hub/lib/abbreviateAddress.ts b/hub/lib/abbreviateAddress.ts new file mode 100644 index 0000000000..dddd8766b0 --- /dev/null +++ b/hub/lib/abbreviateAddress.ts @@ -0,0 +1,6 @@ +import type { PublicKey } from '@solana/web3.js'; + +export function abbreviateAddress(address: PublicKey | string, size = 5) { + const base58 = typeof address === 'string' ? address : address.toBase58(); + return base58.slice(0, size) + '…' + base58.slice(-size); +} diff --git a/hub/lib/abbreviateNumber.ts b/hub/lib/abbreviateNumber.ts new file mode 100644 index 0000000000..b404e7a0f9 --- /dev/null +++ b/hub/lib/abbreviateNumber.ts @@ -0,0 +1,38 @@ +import type { BigNumber } from 'bignumber.js'; + +import { formatNumber } from './formatNumber'; + +const ABBREVIATIONS = [ + [1000000000, 'B'], + [1000000, 'M'], + [1000, 'K'], +] as const; + +export function abbreviateNumber( + number: BigNumber | number | bigint, + locale?: string, + options?: Intl.NumberFormatOptions, +) { + for (const [value, symbol] of ABBREVIATIONS) { + if (typeof number === 'number') { + if (number > value) { + const abbr = (number / value).toFixed(2); + return `${abbr}${symbol}`; + } + } else if (typeof number === 'bigint') { + if (number > value) { + const val = BigInt(value); + const str = (number / val).toString(); + const abbr = parseFloat(str).toFixed(2); + return `${abbr}${symbol}`; + } + } else { + if (number.isGreaterThanOrEqualTo(value)) { + const abbr = number.dividedBy(value).toNumber().toFixed(2); + return `${abbr}${symbol}`; + } + } + } + + return formatNumber(number, locale, options); +} diff --git a/hub/lib/capitalize.ts b/hub/lib/capitalize.ts new file mode 100644 index 0000000000..21282cf8b5 --- /dev/null +++ b/hub/lib/capitalize.ts @@ -0,0 +1,3 @@ +export function capitalize(text: string) { + return text[0].toLocaleUpperCase() + text.slice(1); +} diff --git a/hub/lib/cx.ts b/hub/lib/cx.ts new file mode 100644 index 0000000000..8bae39bb3b --- /dev/null +++ b/hub/lib/cx.ts @@ -0,0 +1,3 @@ +import { twMerge } from 'tailwind-merge'; + +export default twMerge; diff --git a/hub/lib/formatDuration.ts b/hub/lib/formatDuration.ts new file mode 100644 index 0000000000..a70362cd7f --- /dev/null +++ b/hub/lib/formatDuration.ts @@ -0,0 +1,55 @@ +import { formatDuration as _formatDuration, Duration } from 'date-fns'; + +const AVAILABLE_FORMATS = [ + 'years', + 'months', + 'weeks', + 'days', + 'hours', + 'minutes', + 'seconds', +] as const; + +const SHORT_MAP = { + years: 'y', + months: 'm', + weeks: 'w', + days: 'd', + hours: 'h', + minutes: 'm', + seconds: 's', +} as const; + +type Args = Parameters; +interface Options extends NonNullable { + format?: typeof AVAILABLE_FORMATS[number][]; + short?: boolean; +} + +export function formatDuration(duration: Duration, options?: Options) { + if (!options?.short) { + return _formatDuration(duration, options); + } + + const formats = options?.format || AVAILABLE_FORMATS; + const parts: string[] = []; + const isLarge = + duration['years'] || + duration['months'] || + duration['weeks'] || + duration['days']; + + for (const format of formats) { + const value = duration[format]; + + if (value || options?.zero) { + const str = SHORT_MAP[format]; + + if (!isLarge || format !== 'seconds') { + parts.push(`${value}${str}`); + } + } + } + + return parts.join(options?.delimiter || ' '); +} diff --git a/hub/lib/formatNumber.ts b/hub/lib/formatNumber.ts new file mode 100644 index 0000000000..4381dd3d0c --- /dev/null +++ b/hub/lib/formatNumber.ts @@ -0,0 +1,60 @@ +import type { BigNumber } from 'bignumber.js'; + +import { getUserLocale } from './getUserLocale'; + +const getFormatter = (() => { + const formatters = new Map string>(); + + return (locale: string, options?: Intl.NumberFormatOptions) => { + // if we pass in options, don't use a cached formatter + if (options) { + if (typeof Intl !== 'undefined') { + const newFormatter = new Intl.NumberFormat(locale, options); + return (number: number | bigint) => newFormatter.format(number); + } + } else if (!formatters.has(locale)) { + if (typeof Intl !== 'undefined') { + const newFormatter = new Intl.NumberFormat(locale, { + maximumFractionDigits: 2, + minimumFractionDigits: 2, + }); + formatters.set(locale, (number) => newFormatter.format(number)); + } else { + formatters.set(locale, (number) => number.toString()); + } + } + + return formatters.get(locale) as (number: number) => string; + }; +})(); + +/** + * Convert a number into a easier to read comma separated string representation + * of the number. The number can be a primitive `number` or `bigint` type, or + * it can be a `BigNumber` from `bignumber.js`. If you know the user locale, + * you can pass that in, otherwise it will try to use the locale from the + * browser, then default to `'en-US'`. + */ +export function formatNumber( + number: BigNumber | number | bigint, + locale?: string, + options?: Intl.NumberFormatOptions, +) { + const format = getFormatter(locale || getUserLocale(), options); + + if (typeof number === 'bigint') { + return format(Number(number)); + } + + if (typeof number === 'number') { + return format(number); + } + + // BigNumber comes with a formatter, so we'll use that unless we have the + // Intl package available. + if (typeof Intl !== 'undefined') { + return format(number.toNumber()); + } + + return number.toFormat(options?.maximumSignificantDigits || 2); +} diff --git a/hub/lib/getDefaultBannerUrl.ts b/hub/lib/getDefaultBannerUrl.ts new file mode 100644 index 0000000000..cf5e317fd5 --- /dev/null +++ b/hub/lib/getDefaultBannerUrl.ts @@ -0,0 +1,12 @@ +import type { PublicKey } from '@solana/web3.js'; + +const NUM_BANNERS = 20; + +export function getDefaultBannerUrl(seed: PublicKey | string) { + const seedStr = typeof seed === 'string' ? seed : seed.toBase58(); + const num = seedStr + .split('') + .reduce((acc, cur) => acc + cur.charCodeAt(0), 0); + const index = (num % NUM_BANNERS) + 1; + return `/banners/${index}.jpg`; +} diff --git a/hub/lib/getGraphqlJsonSchema.ts b/hub/lib/getGraphqlJsonSchema.ts new file mode 100644 index 0000000000..7c39dcd2a6 --- /dev/null +++ b/hub/lib/getGraphqlJsonSchema.ts @@ -0,0 +1,15 @@ +import { getIntrospectionQuery } from 'graphql'; + +export function getGraphqlJsonSchema() { + return fetch(process.env.NEXT_PUBLIC_API_ENDPOINT || '', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + variables: {}, + query: getIntrospectionQuery({ descriptions: false }), + }), + }) + .then((result) => result.json()) + .then((schema) => schema.data) + .catch(() => null); +} diff --git a/hub/lib/getUserLocale.ts b/hub/lib/getUserLocale.ts new file mode 100644 index 0000000000..5425e46f6b --- /dev/null +++ b/hub/lib/getUserLocale.ts @@ -0,0 +1,18 @@ +/** + * Get the user's locale using values on the browser. If unsuccessful, default + * to `'en-US'`. + */ +export function getUserLocale() { + if ( + typeof window === 'undefined' || + typeof window.navigator === 'undefined' + ) { + return 'en-US'; + } + + if (window.navigator.languages?.length) { + return window.navigator.languages[0]; + } + + return window.navigator.language || 'en-US'; +} diff --git a/hub/lib/gqlCacheStorage.ts b/hub/lib/gqlCacheStorage.ts new file mode 100644 index 0000000000..f46eebfff6 --- /dev/null +++ b/hub/lib/gqlCacheStorage.ts @@ -0,0 +1,19 @@ +import * as localforage from 'localforage'; + +export const getName = (jwt: string | null) => { + if (!jwt) { + return 'realms-anon'; + } + + return `realms-${jwt}`; +}; + +export const create = (jwt: string | null) => { + const name = getName(jwt); + return localforage.createInstance({ name }); +}; + +export const destroy = (jwt: string | null) => { + const name = getName(jwt); + return localforage.dropInstance({ name }); +}; diff --git a/hub/lib/ntext.ts b/hub/lib/ntext.ts new file mode 100644 index 0000000000..75af47506c --- /dev/null +++ b/hub/lib/ntext.ts @@ -0,0 +1,7 @@ +export function ntext(count: number, singular: string, plural?: string) { + if (count === 1) { + return singular; + } + + return plural || `${singular}s`; +} diff --git a/hub/lib/richText.ts b/hub/lib/richText.ts new file mode 100644 index 0000000000..017447c583 --- /dev/null +++ b/hub/lib/richText.ts @@ -0,0 +1,460 @@ +import { + EditorState, + convertToRaw, + convertFromRaw, + RawDraftContentBlock, + RawDraftInlineStyleRange, + RawDraftEntityRange, + genKey, + DraftInlineStyleType, +} from 'draft-js'; +import isEqual from 'lodash/isEqual'; + +import { + RichTextDocument, + BlockNode, + InlineNode, + BlockNodeType, + InlineNodeType, + InlineStyle, + BlockStyle, + AnchorNode, +} from '@hub/types/RichTextDocument'; + +type EntityMap = ReturnType['entityMap']; + +function toInlineStyle(s: string): InlineStyle | undefined { + switch (s) { + case 'BOLD': + return InlineStyle.Bold; + case 'CODE': + return InlineStyle.Code; + case 'ITALIC': + return InlineStyle.Italic; + case 'STRIKETHROUGH': + return InlineStyle.Strikethrough; + case 'UNDERLINE': + return InlineStyle.Underline; + } +} + +function fromInlineStyle(s: InlineStyle): DraftInlineStyleType | undefined { + switch (s) { + case InlineStyle.Bold: + return 'BOLD'; + case InlineStyle.Code: + return 'CODE'; + case InlineStyle.Italic: + return 'ITALIC'; + case InlineStyle.Strikethrough: + return 'STRIKETHROUGH'; + case InlineStyle.Underline: + return 'UNDERLINE'; + case InlineStyle.Small: + return undefined; + } +} + +function toBlockStyle(s: string): BlockStyle { + switch (s) { + case 'paragraph': + return BlockStyle.P; + case 'header-one': + return BlockStyle.H1; + case 'header-two': + return BlockStyle.H2; + case 'header-three': + return BlockStyle.H3; + case 'header-four': + return BlockStyle.H4; + case 'header-five': + return BlockStyle.H5; + case 'header-six': + return BlockStyle.H6; + case 'unordered-list-item': + return BlockStyle.UL; + case 'ordered-list-item': + return BlockStyle.OL; + case 'blockquote': + return BlockStyle.Blockquote; + case 'code-block': + return BlockStyle.Codeblock; + case 'atomic': + return BlockStyle.P; + default: + return BlockStyle.P; + } +} + +function fromBlockStyle(s: BlockStyle) { + switch (s) { + case BlockStyle.P: + return 'paragraph'; + case BlockStyle.H1: + return 'header-one'; + case BlockStyle.H2: + return 'header-two'; + case BlockStyle.H3: + return 'header-three'; + case BlockStyle.H4: + return 'header-four'; + case BlockStyle.H5: + return 'header-five'; + case BlockStyle.H6: + return 'header-six'; + case BlockStyle.UL: + return 'unordered-list-item'; + case BlockStyle.OL: + return 'ordered-list-item'; + case BlockStyle.Blockquote: + return 'blockquote'; + case BlockStyle.Codeblock: + return 'code-block'; + default: + return 'unstyled'; + } +} + +function entityToNode( + entityMap: EntityMap, + key: string | number, + content: C, +) { + const entity = entityMap[key]; + + if (!entity) { + return null; + } + + switch (entity.type) { + case 'IMAGE': + return ({ + t: InlineNodeType.Anchor, + c: content, + u: entity.data.src, + } as unknown) as AnchorNode; + case 'LINK': + return ({ + t: InlineNodeType.Anchor, + c: content, + u: entity.data.url, + } as unknown) as AnchorNode; + default: + return null; + } +} + +function blockToNode( + block: RawDraftContentBlock, + entityMap: EntityMap, +): BlockNode { + // We're going to step through each character of the block. If the char falls + // within the interval of either an entity or an inline style, we're going to + // create a new child from that section. + const c: BlockNode['c'] = []; + const text = block.text; + + // This block contains either an entity or some inline styling. We need to + // handle that. + if (block.inlineStyleRanges.length || block.entityRanges.length) { + // What character from the DraftJsBlock have we visited, but not yet added + // to the canonical BlockNode. + let curText: string[] = []; + // Which inline styles apply to the `curText`. + let curStyles: InlineStyle[] = []; + // Which entity, if any, is the curText a part of. + let curEntityKey: number | undefined = undefined; + // What inline nodes does the current entity already have. + let curEntityContent: InlineNode[] = []; + + for (let i = 0; i < text.length; i++) { + // We are concerned with just one character from the text at a time. + const char = text[i]; + // Which styles are applicable to the character we've visiting + const newStyles: InlineStyle[] = []; + // Which entity, if any, does this character belong to. + let newEntityKey: number | undefined = undefined; + + block.inlineStyleRanges.forEach((range) => { + // If the character falls within the range of any inline styles, add + // the style to the style list. + if (i >= range.offset && i < range.offset + range.length) { + const style = toInlineStyle(range.style); + + if (style) { + newStyles.push(style); + } + } + }); + + block.entityRanges.forEach((range) => { + // If the character falls in the range of any entity, mark it. NOTE: an + // assumption is being made here that a character can only belong to a + // single entity at a time. + if (i >= range.offset && i < range.offset + range.length) { + newEntityKey = range.key; + } + }); + + // If neither the styles have changed, nor has the entity, assume this + // character is a part of the previous node. + if (isEqual(newStyles, curStyles) && newEntityKey === curEntityKey) { + curText.push(char); + } + // Otherwise, we're going to push the existing node to the appropriate + // parent, and start a new node + else { + // If the existing node is empty, don't bother. + if (curText.length) { + const node = { + t: InlineNodeType.Inline as const, + c: curText.join(''), + s: curStyles, + }; + + // If the current node is not a part of any entity, then the node's + // parent is the BlockNode itself. + if (curEntityKey === undefined) { + c.push(node); + } + // Otherwise, the current node's parent is the entity it belongs to + else { + curEntityContent.push(node); + + // If a new entity is being created, push the current entity to the + // BlockNode and start a new entity. + if (curEntityKey !== newEntityKey) { + const node = entityToNode( + entityMap, + curEntityKey, + curEntityContent, + ); + + if (node) { + c.push(node); + } + + curEntityContent = []; + } + } + } + + // Since we pushed the old node, we need to establish a new one. + curText = [char]; + curStyles = newStyles; + } + + // We're don exploring this character, so update the entity pointer. + curEntityKey = newEntityKey; + } + + // We're done exploring each character. At this point, there may be a node + // that we haven't pushed to any parent yet. + const remainder = curText.length + ? { + t: InlineNodeType.Inline as const, + c: curText.join(''), + s: curStyles, + } + : undefined; + + // If we do have such a node... + if (remainder) { + // If the node is a part of an entity, add it to the entity, then push + // the entity to the BlockNode + if (curEntityKey !== undefined) { + curEntityContent.push(remainder); + + const node = entityToNode(entityMap, curEntityKey, curEntityContent); + + if (node) { + c.push(node); + } + } + // Otherwise, push the node directly to the BlockNode; + else { + c.push(remainder); + } + } + } + // This block does not have any entities or styling. + else { + c.push({ + t: InlineNodeType.Inline, + c: text, + }); + } + + return { + c, + t: BlockNodeType.Block, + s: toBlockStyle(block.type), + }; +} + +function nodeToBlock(node: BlockNode, entityKeyIdx = 0) { + const key = genKey(); + const type = fromBlockStyle(node.s); + const depth = 0; + const entityRanges: RawDraftEntityRange[] = []; + const inlineStyleRanges: RawDraftInlineStyleRange[] = []; + const entityMap: EntityMap = {}; + + // Keep track of the current line of text + let text = ''; + + // For each type of inline style, if it is applicable, we need to know at + // what index the styling starts. + const styleIndexStart: Record = { + [InlineStyle.Bold]: undefined, + [InlineStyle.Code]: undefined, + [InlineStyle.Italic]: undefined, + [InlineStyle.Strikethrough]: undefined, + [InlineStyle.Underline]: undefined, + [InlineStyle.Small]: undefined, + }; + + const keys = Object.keys(styleIndexStart) as InlineStyle[]; + + function handleInlineNode(inlineNode: InlineNode) { + // For every possible inline style, if that specific style no longer + // applies to the node we're looking at, we need to close out its + // applicable range. + keys.forEach((style) => { + const offset = styleIndexStart[style]; + + // The style was started, but does not apply to this node. + if ( + offset !== undefined && + !(inlineNode.s || []).find((s) => s === style) + ) { + const endIndex = text.length; + const inlineStyle = fromInlineStyle(style); + + if (inlineStyle) { + inlineStyleRanges.push({ + offset, + style: inlineStyle, + length: endIndex - offset, + }); + styleIndexStart[style] = undefined; + } + } + }); + + // For all the styles on this node, if it hasn't already been done, we need + // to being its range. + inlineNode.s?.forEach((style) => { + if (styleIndexStart[style] === undefined) { + styleIndexStart[style] = text.length; + } + }); + + // Add the node content to this block. + text = text + inlineNode.c; + } + + for (const nodeChild of node.c) { + // In the case of an anchor node, we need to convert the node into an + // entity. After we do that, we can handle its contents like a regular + // list of inline nodes. + if (nodeChild.t === InlineNodeType.Anchor) { + const type = 'LINK'; + const mutability = 'MUTABLE' as const; + const data = { url: nodeChild.u }; + const entity = { type, mutability, data }; + const offset = text.length; + const length = nodeChild.c.reduce((acc, c) => acc + c.c.length, 0); + + entityRanges.push({ + key: entityKeyIdx, + offset, + length, + }); + entityMap[entityKeyIdx] = entity; + entityKeyIdx++; + + // Now that the entity is created, handle the children like regular + // inline nodes. + for (const child of nodeChild.c) { + handleInlineNode(child); + } + } else { + handleInlineNode(nodeChild); + } + } + + // For all the style ranges that have been established, add them to the block + keys.forEach((style) => { + const offset = styleIndexStart[style]; + if (offset !== undefined) { + const endIndex = text.length; + const inlineStyle = fromInlineStyle(style); + + if (inlineStyle) { + inlineStyleRanges.push({ + offset, + style: inlineStyle, + length: endIndex - offset, + }); + } + } + }); + + return { + entityMap, + block: { key, type, depth, entityRanges, inlineStyleRanges, text }, + }; +} + +export function fromEditorState(editorState: EditorState): RichTextDocument { + const raw = convertToRaw(editorState.getCurrentContent()); + const content = raw.blocks.map((block) => blockToNode(block, raw.entityMap)); + return { content, attachments: [] }; +} + +export function toEditorState(document: RichTextDocument): EditorState { + return EditorState.createWithContent( + convertFromRaw( + document.content.reduce( + (acc, node) => { + if (node.t === BlockNodeType.Block) { + const { block, entityMap } = nodeToBlock( + node, + Object.keys(acc.entityMap).length, + ); + acc.blocks.push(block); + Object.assign(acc.entityMap, entityMap); + } + return acc; + }, + { blocks: [], entityMap: {} } as { + blocks: RawDraftContentBlock[]; + entityMap: EntityMap; + }, + ), + ), + ); +} + +export function isEmpty(document: RichTextDocument) { + if (document.attachments.length) { + return false; + } + + for (const block of document.content) { + if (block.t === BlockNodeType.Image) { + return false; + } + + if (block.t === BlockNodeType.Block) { + for (const child of block.c) { + if (child.c.length > 0) { + return false; + } + } + } + } + + return true; +} diff --git a/hub/lib/signature.ts b/hub/lib/signature.ts new file mode 100644 index 0000000000..cedb352d31 --- /dev/null +++ b/hub/lib/signature.ts @@ -0,0 +1,9 @@ +export function toHex(signature: Uint8Array) { + return Array.from(signature) + .map((n) => n.toString(16).padStart(2, '0')) + .join(''); +} + +export function toUint8Array(msg: string) { + return new TextEncoder().encode(msg); +} diff --git a/hub/providers/Cluster/index.tsx b/hub/providers/Cluster/index.tsx new file mode 100644 index 0000000000..301fddae13 --- /dev/null +++ b/hub/providers/Cluster/index.tsx @@ -0,0 +1,85 @@ +import { WalletAdapterNetwork } from '@solana/wallet-adapter-base'; +import { clusterApiUrl, Connection } from '@solana/web3.js'; +import { createContext, useState } from 'react'; + +const DEVNET_RPC_ENDPOINT = + process.env.DEVNET_RPC || 'https://api.dao.devnet.solana.com/'; +const MAINNET_RPC_ENDPOINT = + process.env.MAINNET_RPC || + 'http://realms-realms-c335.mainnet.rpcpool.com/258d3727-bb96-409d-abea-0b1b4c48af29/'; +const TESTNET_RPC_ENDPOINT = 'http://127.0.0.1:8899'; + +export enum ClusterType { + Devnet, + Mainnet, + Testnet, +} + +interface Cluster { + type: ClusterType; + connection: Connection; + endpoint: string; + network: WalletAdapterNetwork; + rpcEndpoint: string; +} + +export const DevnetCluster: Cluster = { + type: ClusterType.Devnet, + connection: new Connection(DEVNET_RPC_ENDPOINT, 'recent'), + endpoint: clusterApiUrl('devnet'), + network: WalletAdapterNetwork.Devnet, + rpcEndpoint: DEVNET_RPC_ENDPOINT, +}; + +export const MainnetCluster: Cluster = { + type: ClusterType.Mainnet, + connection: new Connection(MAINNET_RPC_ENDPOINT, 'recent'), + endpoint: clusterApiUrl('mainnet-beta'), + network: WalletAdapterNetwork.Mainnet, + rpcEndpoint: MAINNET_RPC_ENDPOINT, +}; + +export const TestnetCluster: Cluster = { + type: ClusterType.Testnet, + connection: new Connection(TESTNET_RPC_ENDPOINT, 'recent'), + endpoint: clusterApiUrl('testnet'), + network: WalletAdapterNetwork.Testnet, + rpcEndpoint: TESTNET_RPC_ENDPOINT, +}; + +interface Value { + cluster: Cluster; + type: ClusterType; + setType(type: ClusterType): void; +} + +export const DEFAULT: Value = { + cluster: DevnetCluster, + type: ClusterType.Devnet, + setType: () => { + throw new Error('Not implemented'); + }, +}; + +export const context = createContext(DEFAULT); + +interface Props { + children: React.ReactNode; +} + +export function ClusterProvider(props: Props) { + const [type, setType] = useState(ClusterType.Mainnet); + + const cluster = + type === ClusterType.Devnet + ? DevnetCluster + : type === ClusterType.Testnet + ? TestnetCluster + : MainnetCluster; + + return ( + + {props.children} + + ); +} diff --git a/hub/providers/GraphQL/exchanges/auth.ts b/hub/providers/GraphQL/exchanges/auth.ts new file mode 100644 index 0000000000..1fb87944d1 --- /dev/null +++ b/hub/providers/GraphQL/exchanges/auth.ts @@ -0,0 +1,39 @@ +import { makeOperation } from '@urql/core'; +import { authExchange } from '@urql/exchange-auth'; + +export const auth = authExchange<{ token?: string }>({ + addAuthToOperation: ({ authState, operation }) => { + const token = authState?.token || localStorage.getItem('user'); + + if (!token) { + return operation; + } + + const fetchOptions = + typeof operation.context.fetchOptions === 'function' + ? operation.context.fetchOptions() + : operation.context.fetchOptions || {}; + + return makeOperation(operation.kind, operation, { + ...operation.context, + fetchOptions: { + ...fetchOptions, + headers: { + ...fetchOptions.headers, + Authorization: `Bearer ${token}`, + }, + }, + }); + }, + getAuth: async ({ authState }) => { + if (!authState) { + const token = localStorage.getItem('user'); + + if (token) { + return { token }; + } + } + + return null; + }, +}); diff --git a/hub/providers/GraphQL/exchanges/graphcache.ts b/hub/providers/GraphQL/exchanges/graphcache.ts new file mode 100644 index 0000000000..93cf3f5be1 --- /dev/null +++ b/hub/providers/GraphQL/exchanges/graphcache.ts @@ -0,0 +1,158 @@ +import { offlineExchange, StorageAdapter } from '@urql/exchange-graphcache'; +import { IntrospectionData } from '@urql/exchange-graphcache/dist/types/ast'; +import { gql } from 'urql'; + +import { + feedItemPostParts, + feedItemProposalParts, +} from '@hub/components/Home/Feed/gql'; +import * as gqlStores from '@hub/lib/gqlCacheStorage'; +import { FeedItemSort } from '@hub/types/FeedItemSort'; +import { FeedItemVoteType } from '@hub/types/FeedItemVoteType'; + +const makeStorage = async (jwt: string | null) => { + if (typeof window === 'undefined') { + return undefined; + } + + const store = gqlStores.create(jwt); + const cache = (await store.getItem('data')) || {}; + + const storage: StorageAdapter = { + writeData(delta) { + Object.assign(cache, delta); + return store.setItem('data', cache); + }, + async readData() { + const local = (await store.getItem('data')) || null; + Object.assign(cache, local); + return cache; + }, + writeMetadata(data) { + store.setItem('metadata', data); + }, + async readMetadata() { + const metadataJson = (await store.getItem('metadata')) || null; + return metadataJson; + }, + }; + return storage; +}; + +export const graphcache = async ( + jwt: string | null, + schema?: IntrospectionData, +) => { + const storage = await makeStorage(jwt); + return offlineExchange({ + schema, + storage, + keys: { + ClippedRichTextDocument: () => null, + Realm: (realm) => realm.publicKey as string, + RealmMember: (member) => member.publicKey as string, + RealmPost: (post) => post.id as string, + RealmProposal: (proposal) => proposal.publicKey as string, + RealmProposalVoteBreakdown: () => null, + RealmTreasury: (treasury) => treasury.belongsTo as string, + User: (user) => user.publicKey as string, + }, + updates: { + Mutation: { + createPost(_result, args, cache) { + for (const sort of Object.values(FeedItemSort)) { + cache.invalidate( + { + __typename: 'Query', + }, + 'feed', + { + sort, + first: 10, + realm: args.realm, + }, + ); + } + }, + }, + }, + optimistic: { + voteOnFeedItem(args, cache) { + const postFragment = gql` + fragment _ on RealmFeedItemPost { + ${feedItemPostParts} + } + `; + + const proposalFragment = gql` + fragment _ on RealmFeedItemProposal { + ${feedItemProposalParts} + } + `; + + const currentPost = cache.readFragment(postFragment, { + id: args.feedItemId, + }); + + const currentProposal = cache.readFragment(proposalFragment, { + id: args.feedItemId, + }); + + const feedItem = currentPost || currentProposal; + + if (feedItem) { + let newVote: null | FeedItemVoteType = FeedItemVoteType.Approve; + let score = feedItem.score; + + if (feedItem.myVote === args.vote) { + newVote = null; + + if (args.vote === FeedItemVoteType.Approve) { + score -= 1; + } else { + score += 1; + } + } else { + newVote = args.vote as FeedItemVoteType; + + if (!feedItem.myVote) { + if (args.vote === FeedItemVoteType.Approve) { + score += 1; + } else { + score -= 1; + } + } else { + if (feedItem.myVote === FeedItemVoteType.Approve) { + score -= 2; + } else { + score += 2; + } + } + } + + if (currentPost) { + return { + __typename: 'RealmFeedItemPost', + ...currentPost, + id: args.feedItemId, + myVote: newVote, + score: score, + }; + } + + if (currentProposal) { + return { + __typename: 'RealmFeedItemProposal', + ...currentProposal, + id: args.feedItemId, + myVote: newVote, + score: score, + }; + } + } + + return null; + }, + }, + }); +}; diff --git a/hub/providers/GraphQL/exchanges/index.ts b/hub/providers/GraphQL/exchanges/index.ts new file mode 100644 index 0000000000..98d4d2a6d1 --- /dev/null +++ b/hub/providers/GraphQL/exchanges/index.ts @@ -0,0 +1,21 @@ +import { IntrospectionData } from '@urql/exchange-graphcache/dist/types/ast'; +import { multipartFetchExchange } from '@urql/exchange-multipart-fetch'; +import { persistedFetchExchange } from '@urql/exchange-persisted-fetch'; +import { requestPolicyExchange } from '@urql/exchange-request-policy'; +import { dedupExchange, Exchange } from 'urql'; + +import { auth } from './auth'; +import { graphcache } from './graphcache'; + +export const exchanges = async ( + jwt: string | null, + schema?: IntrospectionData, +) => [ + // Ordering matters + auth, + dedupExchange, + requestPolicyExchange({}) as Exchange, + await graphcache(jwt, schema), + persistedFetchExchange({ preferGetForPersistedQueries: true }), + multipartFetchExchange, +]; diff --git a/hub/providers/GraphQL/index.tsx b/hub/providers/GraphQL/index.tsx new file mode 100644 index 0000000000..483bd2c3f6 --- /dev/null +++ b/hub/providers/GraphQL/index.tsx @@ -0,0 +1,40 @@ +import { IntrospectionData } from '@urql/exchange-graphcache/dist/types/ast'; +import React, { useEffect, useState } from 'react'; +import { createClient, Client, Provider, Exchange } from 'urql'; + +import { useJWT } from '@hub/hooks/useJWT'; + +import { exchanges } from './exchanges'; + +const urqlClient = async (jwt: string | null, schema?: IntrospectionData) => { + return createClient({ + exchanges: (await exchanges(jwt, schema)) as Exchange[], + url: process.env.NEXT_PUBLIC_API_ENDPOINT || '', + }); +}; + +interface Props { + children?: React.ReactNode; +} + +export function GraphQLProvider(props: Props) { + const [jwt] = useJWT(); + const [client, setClient] = useState(null); + + useEffect(() => { + const schema = + typeof window !== 'undefined' + ? // @ts-ignore + window['__SCHEMA__'] + : undefined; + + setClient(null); + urqlClient(jwt, schema).then(setClient); + }, [jwt]); + + if (!client) { + return null; + } + + return {props.children}; +} diff --git a/hub/providers/JWT/index.tsx b/hub/providers/JWT/index.tsx new file mode 100644 index 0000000000..5e72b4189e --- /dev/null +++ b/hub/providers/JWT/index.tsx @@ -0,0 +1,74 @@ +import { createContext, useCallback, useEffect, useState } from 'react'; + +import * as gqlStores from '@hub/lib/gqlCacheStorage'; + +interface Value { + jwt: null | string; + setJwt(jwt: null | string): void; +} + +export const DEFAULT: Value = { + jwt: null, + setJwt: () => { + throw new Error('Not implemented'); + }, +}; + +export const context = createContext(DEFAULT); + +interface Props { + children: React.ReactNode; +} + +export function JWTProvider(props: Props) { + const [jwt, _setJwt] = useState( + typeof localStorage === 'undefined' ? null : localStorage.getItem('user'), + ); + + const handleStorageChange = useCallback((event: StorageEvent) => { + if (event.storageArea === localStorage && event.key === 'user') { + gqlStores.destroy(event.oldValue); + _setJwt(event.newValue); + } + }, []); + + const setJwt = useCallback( + (jwt: string | null) => { + if (typeof localStorage !== 'undefined') { + if (jwt) { + localStorage.setItem('user', jwt); + _setJwt((current) => { + gqlStores.destroy(current); + return jwt; + }); + } else { + _setJwt((current) => { + gqlStores.destroy(current); + return null; + }); + localStorage.removeItem('user'); + } + } + }, + [typeof localStorage], + ); + + useEffect(() => { + if (typeof window !== 'undefined') { + window.addEventListener('storage', handleStorageChange); + _setJwt(localStorage.getItem('user')); + } + + return () => { + if (typeof window !== 'undefined') { + window.removeEventListener('storage', handleStorageChange); + } + }; + }, []); + + return ( + + {props.children} + + ); +} diff --git a/hub/providers/Root.tsx b/hub/providers/Root.tsx new file mode 100644 index 0000000000..140efc20d7 --- /dev/null +++ b/hub/providers/Root.tsx @@ -0,0 +1,41 @@ +import React from 'react'; + +import cx from '@hub/lib/cx'; + +import { ClusterProvider } from './Cluster'; +import { GraphQLProvider } from './GraphQL'; +import { JWTProvider } from './JWT'; +import { ToastProvider } from './Toast'; +import { UserPrefsProvider } from './UserPrefs'; +import { WalletProvider } from './Wallet'; + +interface Props { + children: React.ReactNode; +} + +export function RootProvider(props: Props) { + return ( + + + + + + {props.children} + + + + + + ); +} diff --git a/hub/providers/Toast/index.tsx b/hub/providers/Toast/index.tsx new file mode 100644 index 0000000000..94902580f3 --- /dev/null +++ b/hub/providers/Toast/index.tsx @@ -0,0 +1,168 @@ +import ErrorIcon from '@carbon/icons-react/lib/Error'; +import FaceSatisfiedIcon from '@carbon/icons-react/lib/FaceSatisfied'; +import WarningIcon from '@carbon/icons-react/lib/Warning'; +import * as Toast from '@radix-ui/react-toast'; +import React, { createContext, useEffect, useState } from 'react'; + +import cx from '@hub/lib/cx'; + +export enum ToastType { + Error, + Success, + Warning, +} + +interface ToastModel { + type: ToastType; + message: string; + title?: string; + id: string; +} + +interface Value { + publish(alert: Omit): void; +} + +export const DEFAULT: Value = { + publish: () => { + throw new Error('Not implemented'); + }, +}; + +export const context = createContext(DEFAULT); + +const makeId = () => Math.floor(Math.random() * 1000000000).toString(); + +const defaultTitle = (toast: ToastModel) => { + switch (toast.type) { + case ToastType.Error: + return 'Error'; + case ToastType.Success: + return 'Success!'; + case ToastType.Warning: + return 'Warning'; + } +}; + +const icon = (toast: ToastModel) => { + switch (toast.type) { + case ToastType.Error: + return ; + case ToastType.Success: + return ; + case ToastType.Warning: + return ; + } +}; + +function ToastItem(props: ToastModel & { onOpenChange(open: boolean): void }) { + const iconElement = icon(props); + const [show, setShow] = useState(false); + + useEffect(() => { + setShow(true); + }, []); + + return ( + + +
+
+ {React.cloneElement(iconElement, { + className: cx(iconElement.props.className, 'h-4', 'w-4'), + })} +
+ + {props.title || defaultTitle(props)} + +
+
+ + {props.message} + +
+
+
+ ); +} + +interface Props { + className?: string; + children?: React.ReactNode; +} + +export function ToastProvider(props: Props) { + const [toasts, setToasts] = useState([]); + + return ( + + + setToasts((current) => + current.concat({ + ...toast, + id: makeId(), + }), + ), + }} + > + {props.children} + + {toasts.map((toast) => ( + + setToasts((current) => { + if (open && !current.map((c) => c.id).includes(toast.id)) { + return current.concat(toast); + } else if (!open) { + return current.filter((c) => c.id !== toast.id); + } else { + return current; + } + }) + } + /> + ))} + + + ); +} diff --git a/hub/providers/UserPrefs/index.tsx b/hub/providers/UserPrefs/index.tsx new file mode 100644 index 0000000000..49882f54ed --- /dev/null +++ b/hub/providers/UserPrefs/index.tsx @@ -0,0 +1,93 @@ +import { createContext, useEffect, useState } from 'react'; + +import { FeedItemSort } from '@hub/types/FeedItemSort'; + +const LOCAL_STORAGE_KEY = 'userPrefs'; + +export interface UserPrefs { + defaultFeedSort: { + [key: string]: FeedItemSort; + }; +} + +interface Value { + prefs: UserPrefs; + getFeedSort(key: string): FeedItemSort; + setFeedSort(key: string, sort: FeedItemSort): void; +} + +export const DEFAULT: Value = { + prefs: { defaultFeedSort: {} }, + getFeedSort: () => { + throw new Error('Not implemented'); + }, + setFeedSort: () => { + throw new Error('Not implemented'); + }, +}; + +export const context = createContext(DEFAULT); + +interface Props { + children?: React.ReactNode; +} + +export function UserPrefsProvider(props: Props) { + const [prefs, setPrefs] = useState(null); + + useEffect(() => { + if (typeof window !== 'undefined' && typeof localStorage !== 'undefined') { + try { + const storedPrefs = localStorage.getItem(LOCAL_STORAGE_KEY); + if (storedPrefs) { + const prefs = JSON.parse(storedPrefs); + setPrefs(prefs); + } else { + setPrefs({ + defaultFeedSort: {}, + }); + } + } catch { + setPrefs({ + defaultFeedSort: {}, + }); + } + } + }, []); + + useEffect(() => { + if ( + typeof window !== 'undefined' && + typeof localStorage !== 'undefined' && + prefs + ) { + localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(prefs)); + } + }, [prefs]); + + if (!prefs) { + return null; + } + + return ( + { + return prefs.defaultFeedSort[key] || FeedItemSort.Relevance; + }, + setFeedSort: (key, sort) => { + setPrefs((cur) => ({ + ...cur, + defaultFeedSort: { + ...cur?.defaultFeedSort, + [key]: sort, + }, + })); + }, + }} + > + {props.children} + + ); +} diff --git a/hub/providers/Wallet/index.tsx b/hub/providers/Wallet/index.tsx new file mode 100644 index 0000000000..33d7828809 --- /dev/null +++ b/hub/providers/Wallet/index.tsx @@ -0,0 +1,64 @@ +import { WalletContextState, useWallet } from '@solana/wallet-adapter-react'; +import type { PublicKey } from '@solana/web3.js'; +import React, { createContext } from 'react'; + +import { useWalletSelector } from '@hub/hooks/useWalletSelector'; +import { WalletSelector } from '@hub/providers/WalletSelector'; + +interface Value { + connect(): Promise; + publicKey?: PublicKey; + signMessage: NonNullable; + signTransation: NonNullable; +} + +export const DEFAULT: Value = { + connect: async () => { + throw new Error('Not implemented'); + }, + publicKey: undefined, + signMessage: async () => { + throw new Error('Not implemented'); + }, + signTransation: async () => { + throw new Error('Not implemented'); + }, +}; + +export const context = createContext(DEFAULT); + +interface Props { + children?: React.ReactNode; +} + +function WalletProviderInner(props: Props) { + const { wallet } = useWallet(); + const { getAdapter } = useWalletSelector(); + + return ( + getAdapter().then(({ publicKey }) => publicKey), + publicKey: wallet?.adapter.publicKey || undefined, + signMessage: async (message) => { + const { signMessage } = await getAdapter(); + return signMessage(message); + }, + signTransation: async (transaction) => { + const { signTransaction } = await getAdapter(); + return signTransaction(transaction); + }, + }} + > + {props.children} + + ); +} + +export function WalletProvider(props: Props) { + return ( + + {props.children} + + ); +} diff --git a/hub/providers/WalletSelector/index.tsx b/hub/providers/WalletSelector/index.tsx new file mode 100644 index 0000000000..88d0b1450c --- /dev/null +++ b/hub/providers/WalletSelector/index.tsx @@ -0,0 +1,257 @@ +import { Adapter, WalletReadyState } from '@solana/wallet-adapter-base'; +import { + ConnectionProvider, + WalletProvider as _WalletProvider, + useWallet, + WalletContextState, +} from '@solana/wallet-adapter-react'; +import { + GlowWalletAdapter, + PhantomWalletAdapter, + SlopeWalletAdapter, + SolflareWalletAdapter, + TorusWalletAdapter, +} from '@solana/wallet-adapter-wallets'; +import type { PublicKey } from '@solana/web3.js'; +import React, { createContext, useEffect, useMemo, useState } from 'react'; + +import { RealmCircle } from '@hub/components/branding/RealmCircle'; +import { SolanaLogo } from '@hub/components/branding/SolanaLogo'; +import * as Dialog from '@hub/components/controls/Dialog'; +import { useCluster } from '@hub/hooks/useCluster'; +import { useToast, ToastType } from '@hub/hooks/useToast'; +import cx from '@hub/lib/cx'; + +interface Wallet { + adapter: Adapter; + publicKey: PublicKey; + signMessage: NonNullable; + signTransaction: NonNullable; +} + +interface Value { + getAdapter(): Promise; +} + +export const DEFAULT: Value = { + getAdapter: async () => { + throw new Error('Not implemented'); + }, +}; + +export const context = createContext(DEFAULT); + +let resolveAdapterPromise: ((value: Wallet) => void) | null = null; +const adapterPromise = new Promise((resolve) => { + resolveAdapterPromise = resolve; +}); + +interface Props { + children?: React.ReactNode; +} + +function WalletSelectorInner(props: Props) { + const { wallets, signMessage, signTransaction, select } = useWallet(); + const [adapter, setAdapter] = useState(null); + const [selectorOpen, setSelectorOpen] = useState(false); + const [publicKey, setPublicKey] = useState(null); + const [shouldConnect, setShouldConnect] = useState(false); + const toast = useToast(); + + useEffect(() => { + if (typeof localStorage !== 'undefined') { + const adapterName = JSON.parse( + localStorage.getItem('walletName') || '""', + ); + + const adapter = wallets.find( + (wallet) => wallet.adapter.name === adapterName, + )?.adapter; + + if (adapter) { + setAdapter(adapter); + } + } + }, []); + + useEffect(() => { + async function connect() { + if (adapter && shouldConnect) { + if ( + adapter.connected && + adapter.publicKey && + signMessage && + signTransaction && + adapter.publicKey + ) { + return adapter.publicKey; + } + + await adapter.disconnect(); + await adapter.connect(); + + let publicKey = adapter.publicKey; + + if (!publicKey) { + // turn the wallet on and off in an attempt to get the key + await adapter.disconnect(); + await adapter.connect(); + } + + publicKey = adapter.publicKey; + + if (!publicKey) { + // if we still don't have the key, something has gone wrong + throw new Error('No public key'); + } + + select(adapter.name); + return publicKey; + } + } + + connect() + .then((publicKey) => { + if (publicKey) { + setPublicKey(publicKey); + } + }) + .catch((e) => + toast.publish({ + type: ToastType.Error, + title: 'Could not connect to wallet', + message: e instanceof Error ? e.message : 'Something went wrong', + }), + ); + }, [adapter, shouldConnect]); + + useEffect(() => { + if (signMessage && signTransaction && publicKey && adapter) { + setSelectorOpen(false); + + resolveAdapterPromise?.({ + adapter, + publicKey, + signMessage, + signTransaction, + }); + } + }, [signMessage, signTransaction, publicKey, adapter]); + + const adapters = wallets.filter( + (adapter) => + adapter.readyState === WalletReadyState.Installed || + adapter.readyState === WalletReadyState.Loadable, + ); + + return ( + { + setShouldConnect(true); + + if (!adapter) { + setSelectorOpen(true); + } + + return adapterPromise; + }, + }} + > + {props.children} + + + + + +
+ + +
+
Which
+ {' '} +
Solana wallet would
+
+
you like to use?
+
+
+ + {adapters.map((adapter) => ( + + ))} + +
+
+
+
+
+ ); +} + +export function WalletSelector(props: Props) { + const [cluster] = useCluster(); + + const supportedWallets = useMemo( + () => [ + new PhantomWalletAdapter(), + new GlowWalletAdapter(), + new SlopeWalletAdapter(), + new SolflareWalletAdapter({ network: cluster.network }), + new TorusWalletAdapter(), + ], + [cluster.network], + ); + + return ( + + <_WalletProvider wallets={supportedWallets}> + {props.children} + + + ); +} diff --git a/hub/tsconfig.json b/hub/tsconfig.json new file mode 100644 index 0000000000..20d5e2e03c --- /dev/null +++ b/hub/tsconfig.json @@ -0,0 +1,6 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "strict": true + } +} diff --git a/hub/types/FeedItemSort.ts b/hub/types/FeedItemSort.ts new file mode 100644 index 0000000000..7ad13512bc --- /dev/null +++ b/hub/types/FeedItemSort.ts @@ -0,0 +1,5 @@ +export enum FeedItemSort { + New = 'New', + Relevance = 'Relevance', + TopAllTime = 'TopAllTime', +} diff --git a/hub/types/FeedItemType.ts b/hub/types/FeedItemType.ts new file mode 100644 index 0000000000..3e37dec556 --- /dev/null +++ b/hub/types/FeedItemType.ts @@ -0,0 +1,4 @@ +export enum FeedItemType { + Post = 'Post', + Proposal = 'Proposal', +} diff --git a/hub/types/FeedItemVoteType.ts b/hub/types/FeedItemVoteType.ts new file mode 100644 index 0000000000..292443a099 --- /dev/null +++ b/hub/types/FeedItemVoteType.ts @@ -0,0 +1,4 @@ +export enum FeedItemVoteType { + Approve = 'Approve', + Disapprove = 'Disapprove', +} diff --git a/hub/types/ProposalState.ts b/hub/types/ProposalState.ts new file mode 100644 index 0000000000..59c819adf2 --- /dev/null +++ b/hub/types/ProposalState.ts @@ -0,0 +1,11 @@ +export enum ProposalState { + Cancelled = 'Cancelled', + Completed = 'Completed', + Defeated = 'Defeated', + Draft = 'Draft', + Executable = 'Executable', + ExecutingWithErrors = 'ExecutingWithErrors', + Finalizing = 'Finalizing', + SigningOff = 'SigningOff', + Voting = 'Voting', +} diff --git a/hub/types/ProposalUserVoteType.ts b/hub/types/ProposalUserVoteType.ts new file mode 100644 index 0000000000..65807b1aaf --- /dev/null +++ b/hub/types/ProposalUserVoteType.ts @@ -0,0 +1,6 @@ +export enum ProposalUserVoteType { + Abstain = 'Abstain', + No = 'No', + Veto = 'Veto', + Yes = 'Yes', +} diff --git a/hub/types/Result.ts b/hub/types/Result.ts new file mode 100644 index 0000000000..d4bde0c9b1 --- /dev/null +++ b/hub/types/Result.ts @@ -0,0 +1,177 @@ +export enum Status { + /** + * The Result has completed, but an error has occured + */ + Failed, + /** + * The Result has completed, and the data is available + */ + Ok, + /** + * The Result is currently waiting to complete + */ + Pending, + /** + * The Result is currently waiting to complete, but stale data is available + */ + Stale, +} + +/** + * The Result has completed, but an error has occured + */ +export interface Failed { + _tag: Status.Failed; + /** + * The error that has occured + */ + error: E; +} + +export interface Ok { + _tag: Status.Ok; + /** + * The data that was fetched + */ + data: D; +} + +export interface Pending { + _tag: Status.Pending; +} + +export interface Stale { + _tag: Status.Stale; + /** + * The stale data that is being replaced + */ + data: D; +} + +/** + * Data in various asynchronous states. + */ +export type Result = + | Failed + | Ok + | Pending + | Stale; + +/** + * Creates a `Result` in the `Failed` state. + */ +export function failed(error: E): Failed { + return { _tag: Status.Failed, error }; +} + +/** + * Creates a `Result` in the `Ok` state. + */ +export function ok(data: D): Ok { + return { _tag: Status.Ok, data }; +} + +/** + * Creates a `Result` in the `Pending` state. + */ +export function pending(): Pending { + return { _tag: Status.Pending }; +} + +/** + * Creates a `Result` in the `Stale` state. + */ +export function stale(data: D): Stale { + return { _tag: Status.Stale, data }; +} + +/** + * Map over the data of a `Result` that is in the Ok or Stale states + */ +export function map( + fn: (data: D) => R, +): (result: Result) => Result { + return (result) => { + if (result._tag === Status.Ok || result._tag === Status.Stale) { + return { + _tag: result._tag, + data: fn(result.data), + }; + } + + return result; + }; +} + +/** + * Match over the Result + */ +export function match( + onFailure: (error: E) => R, + onPending: () => R, + onOk: (data: D) => R, + onStale: (data: D) => R, +): (result: Result) => R; +export function match( + onFailure: (error: E) => R, + onPending: () => R, + onOk: (data: D, isStale: boolean) => R, +): (result: Result) => R; +export function match( + onFailure: (error: E) => R, + onPending: () => R, + onOk: (data: D, isStale: boolean) => R, + onStale?: (data: D) => R, +): (result: Result) => R { + return (result) => { + if (isFailed(result)) { + return onFailure(result.error); + } else if (isPending(result)) { + return onPending(); + } else if (isStale(result)) { + if (onStale) { + return onStale(result.data); + } else { + return onOk(result.data, true); + } + } else { + return onOk(result.data, false); + } + }; +} + +/** + * Determine if a `Result` is a `Failed` + */ +export function isFailed( + result: Result, +): result is Failed { + return result._tag === Status.Failed; +} + +/** + * Determine if a `Result` is an `Ok` + */ +export function isOk( + result: Result, +): result is Ok { + return result._tag === Status.Ok; +} + +/** + * Determine if a `Result` is a `Pending` + */ +export function isPending( + result: Result, +): result is Pending { + return result._tag === Status.Pending; +} + +/** + * Determine if a `Result` is a `Stale` + */ +export function isStale( + result: Result, +): result is Stale { + return result._tag === Status.Stale; +} diff --git a/hub/types/RichTextDocument.ts b/hub/types/RichTextDocument.ts new file mode 100644 index 0000000000..37552a9eb2 --- /dev/null +++ b/hub/types/RichTextDocument.ts @@ -0,0 +1,61 @@ +export enum InlineStyle { + Bold = 'B', + Code = 'C', + Italic = 'I', + Small = 'SM', + Strikethrough = 'S', + Underline = 'U', +} + +export enum BlockStyle { + Blockquote = 'BQ', + Codeblock = 'CB', + H1 = 'H1', + H2 = 'H2', + H3 = 'H3', + H4 = 'H4', + H5 = 'H5', + H6 = 'H6', + OL = 'OL', + P = 'P', + UL = 'UL', +} + +export enum InlineNodeType { + Anchor = 'A', + Inline = 'I', +} + +export enum BlockNodeType { + Block = 'B', + Image = 'IM', +} + +export interface InlineNode { + t: InlineNodeType.Inline; + c: string; + s?: null | InlineStyle[]; +} + +export interface AnchorNode { + t: InlineNodeType.Anchor; + c: InlineNode[]; + u: string; +} + +export interface ImageNode { + t: BlockNodeType.Image; + c: InlineNode[]; + u: string; +} + +export interface BlockNode { + t: BlockNodeType.Block; + c: (AnchorNode | InlineNode)[]; + s: BlockStyle; +} + +export interface RichTextDocument { + attachments: unknown[]; + content: (BlockNode | ImageNode)[]; +} diff --git a/hub/types/decoders/BigNumber.ts b/hub/types/decoders/BigNumber.ts new file mode 100644 index 0000000000..c7d5751c9a --- /dev/null +++ b/hub/types/decoders/BigNumber.ts @@ -0,0 +1,22 @@ +import { BigNumber as _BigNumber } from 'bignumber.js'; +import { Type, success, failure, TypeOf } from 'io-ts'; + +export const BigNumber = new Type<_BigNumber, string, unknown>( + 'BigNumber', + (u: unknown): u is _BigNumber => u instanceof _BigNumber, + (input, context) => { + try { + if (typeof input === 'string') { + const pk = new _BigNumber(input); + return success(pk); + } else { + return failure(input, context); + } + } catch { + return failure(input, context); + } + }, + (a: _BigNumber) => a.toString(), +); + +export type PublicKey = TypeOf; diff --git a/hub/types/decoders/FeedItemType.ts b/hub/types/decoders/FeedItemType.ts new file mode 100644 index 0000000000..edce7bbca2 --- /dev/null +++ b/hub/types/decoders/FeedItemType.ts @@ -0,0 +1,8 @@ +import * as IT from 'io-ts'; + +import { FeedItemType as _FeedItemType } from '../FeedItemType'; + +export const FeedItemTypePost = IT.literal(_FeedItemType.Post); +export const FeedItemTypeProposal = IT.literal(_FeedItemType.Proposal); + +export const FeedItemType = IT.union([FeedItemTypePost, FeedItemTypeProposal]); diff --git a/hub/types/decoders/FeedItemVoteType.ts b/hub/types/decoders/FeedItemVoteType.ts new file mode 100644 index 0000000000..bd56f68174 --- /dev/null +++ b/hub/types/decoders/FeedItemVoteType.ts @@ -0,0 +1,13 @@ +import * as IT from 'io-ts'; + +import { FeedItemVoteType as _FeedItemVoteType } from '../FeedItemVoteType'; + +export const FeedItemVoteTypeApprove = IT.literal(_FeedItemVoteType.Approve); +export const FeedItemVoteTypeDisapprove = IT.literal( + _FeedItemVoteType.Disapprove, +); + +export const FeedItemVoteType = IT.union([ + FeedItemVoteTypeApprove, + FeedItemVoteTypeDisapprove, +]); diff --git a/hub/types/decoders/ProposalState.ts b/hub/types/decoders/ProposalState.ts new file mode 100644 index 0000000000..ce37c42457 --- /dev/null +++ b/hub/types/decoders/ProposalState.ts @@ -0,0 +1,27 @@ +import * as IT from 'io-ts'; + +import { ProposalState as _ProposalState } from '../ProposalState'; + +export const ProposalStateCancelled = IT.literal(_ProposalState.Cancelled); +export const ProposalStateCompleted = IT.literal(_ProposalState.Completed); +export const ProposalStateDefeated = IT.literal(_ProposalState.Defeated); +export const ProposalStateDraft = IT.literal(_ProposalState.Draft); +export const ProposalStateExecutable = IT.literal(_ProposalState.Executable); +export const ProposalStateExecutingWithErrors = IT.literal( + _ProposalState.ExecutingWithErrors, +); +export const ProposalStateFinalizing = IT.literal(_ProposalState.Finalizing); +export const ProposalStateSigningOff = IT.literal(_ProposalState.SigningOff); +export const ProposalStateVoting = IT.literal(_ProposalState.Voting); + +export const ProposalState = IT.union([ + ProposalStateCancelled, + ProposalStateCompleted, + ProposalStateDefeated, + ProposalStateDraft, + ProposalStateExecutable, + ProposalStateExecutingWithErrors, + ProposalStateFinalizing, + ProposalStateSigningOff, + ProposalStateVoting, +]); diff --git a/hub/types/decoders/ProposalUserVoteType.ts b/hub/types/decoders/ProposalUserVoteType.ts new file mode 100644 index 0000000000..d0bbb9d99e --- /dev/null +++ b/hub/types/decoders/ProposalUserVoteType.ts @@ -0,0 +1,17 @@ +import * as IT from 'io-ts'; + +import { ProposalUserVoteType as _ProposalUserVoteType } from '../ProposalUserVoteType'; + +export const ProposalUserVoteTypeAbstain = IT.literal( + _ProposalUserVoteType.Abstain, +); +export const ProposalUserVoteTypeNo = IT.literal(_ProposalUserVoteType.No); +export const ProposalUserVoteTypeVeto = IT.literal(_ProposalUserVoteType.Veto); +export const ProposalUserVoteTypeYes = IT.literal(_ProposalUserVoteType.Yes); + +export const ProposalUserVoteType = IT.union([ + ProposalUserVoteTypeAbstain, + ProposalUserVoteTypeNo, + ProposalUserVoteTypeVeto, + ProposalUserVoteTypeYes, +]); diff --git a/hub/types/decoders/PublicKey.ts b/hub/types/decoders/PublicKey.ts new file mode 100644 index 0000000000..bbef214d0d --- /dev/null +++ b/hub/types/decoders/PublicKey.ts @@ -0,0 +1,22 @@ +import { PublicKey as _PublicKey } from '@solana/web3.js'; +import { Type, success, failure, TypeOf } from 'io-ts'; + +export const PublicKey = new Type<_PublicKey, string, unknown>( + 'PublicKey', + (u: unknown): u is _PublicKey => u instanceof _PublicKey, + (input, context) => { + try { + if (typeof input === 'string') { + const pk = new _PublicKey(input); + return success(pk); + } else { + return failure(input, context); + } + } catch { + return failure(input, context); + } + }, + (a: _PublicKey) => a.toBase58(), +); + +export type PublicKey = TypeOf; diff --git a/hub/types/decoders/RichTextDocument.ts b/hub/types/decoders/RichTextDocument.ts new file mode 100644 index 0000000000..d98f02a686 --- /dev/null +++ b/hub/types/decoders/RichTextDocument.ts @@ -0,0 +1,89 @@ +import * as IT from 'io-ts'; + +import * as RTD from '../RichTextDocument'; + +export const InlineStyleBold = IT.literal(RTD.InlineStyle.Bold); +export const InlineStyleCode = IT.literal(RTD.InlineStyle.Code); +export const InlineStyleItalic = IT.literal(RTD.InlineStyle.Italic); +export const InlineStyleSmall = IT.literal(RTD.InlineStyle.Small); +export const InlineStyleStrikethrough = IT.literal( + RTD.InlineStyle.Strikethrough, +); +export const InlineStyleUnderline = IT.literal(RTD.InlineStyle.Underline); + +export const InlineStyle = IT.union([ + InlineStyleBold, + InlineStyleCode, + InlineStyleItalic, + InlineStyleSmall, + InlineStyleStrikethrough, + InlineStyleUnderline, +]); + +export const BlockStyleBlockquote = IT.literal(RTD.BlockStyle.Blockquote); +export const BlockStyleCodeblock = IT.literal(RTD.BlockStyle.Codeblock); +export const BlockStyleH1 = IT.literal(RTD.BlockStyle.H1); +export const BlockStyleH2 = IT.literal(RTD.BlockStyle.H2); +export const BlockStyleH3 = IT.literal(RTD.BlockStyle.H3); +export const BlockStyleH4 = IT.literal(RTD.BlockStyle.H4); +export const BlockStyleH5 = IT.literal(RTD.BlockStyle.H5); +export const BlockStyleH6 = IT.literal(RTD.BlockStyle.H6); +export const BlockStyleOL = IT.literal(RTD.BlockStyle.OL); +export const BlockStyleP = IT.literal(RTD.BlockStyle.P); +export const BlockStyleUL = IT.literal(RTD.BlockStyle.UL); + +export const BlockStyle = IT.union([ + BlockStyleBlockquote, + BlockStyleCodeblock, + BlockStyleH1, + BlockStyleH2, + BlockStyleH3, + BlockStyleH4, + BlockStyleH5, + BlockStyleH6, + BlockStyleOL, + BlockStyleP, + BlockStyleUL, +]); + +export const InlineNodeTypeAnchor = IT.literal(RTD.InlineNodeType.Anchor); +export const InlineNodeTypeInline = IT.literal(RTD.InlineNodeType.Inline); + +export const InlineNodeType = IT.union([ + InlineNodeTypeAnchor, + InlineNodeTypeInline, +]); + +export const BlockNodeTypeBlock = IT.literal(RTD.BlockNodeType.Block); +export const BlockNodeTypeImage = IT.literal(RTD.BlockNodeType.Image); + +export const BlockNodeType = IT.union([BlockNodeTypeBlock, BlockNodeTypeImage]); + +export const InlineNode = IT.type({ + t: InlineNodeTypeInline, + c: IT.string, + s: IT.union([IT.null, IT.undefined, IT.array(InlineStyle)]), +}); + +export const AnchorNode = IT.type({ + t: InlineNodeTypeAnchor, + c: IT.array(InlineNode), + u: IT.string, +}); + +export const ImageNode = IT.type({ + t: BlockNodeTypeImage, + c: IT.array(InlineNode), + u: IT.string, +}); + +export const BlockNode = IT.type({ + t: BlockNodeTypeBlock, + c: IT.array(IT.union([AnchorNode, InlineNode])), + s: BlockStyle, +}); + +export const RichTextDocument = IT.type({ + attachments: IT.array(IT.unknown), + content: IT.array(IT.union([BlockNode, ImageNode])), +}); diff --git a/package.json b/package.json index 800c9f9c46..1edfe23c90 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "@blockworks-foundation/mango-v4": "^0.0.2", "@bonfida/spl-name-service": "^0.1.47", "@bundlr-network/client": "^0.7.15", + "@carbon/icons-react": "^11.7.0", "@cardinal/namespaces-components": "^2.5.5", "@castlefinance/vault-core": "^0.1.3", "@castlefinance/vault-sdk": "^2.1.2", @@ -57,8 +58,15 @@ "@project-serum/serum": "^0.13.61", "@project-serum/sol-wallet-adapter": "^0.2.6", "@radix-ui/react-aspect-ratio": "^1.0.0", + "@radix-ui/react-dialog": "^1.0.0", "@radix-ui/react-dropdown-menu": "^1.0.0", + "@radix-ui/react-hover-card": "^1.0.0", + "@radix-ui/react-navigation-menu": "^1.0.0", + "@radix-ui/react-select": "^1.0.0", + "@radix-ui/react-separator": "^1.0.0", "@radix-ui/react-tabs": "^1.0.0", + "@radix-ui/react-toast": "^1.0.0", + "@radix-ui/react-toolbar": "^1.0.0", "@sentry/nextjs": "^6.19.7", "@solana/buffer-layout": "^4.0.0", "@solana/governance-program-library": "npm:@civic/governance-program-library@0.16.9-beta.2", @@ -70,14 +78,21 @@ "@solana/wallet-adapter-exodus": "^0.1.8", "@solana/wallet-adapter-glow": "^0.1.1", "@solana/wallet-adapter-phantom": "^0.9.3", + "@solana/wallet-adapter-react": "^0.15.18", "@solana/wallet-adapter-solflare": "^0.6.6", "@solana/wallet-adapter-sollet": "^0.11.1", "@solana/wallet-adapter-torus": "^0.11.11", + "@solana/wallet-adapter-wallets": "^0.18.7", "@solana/web3.js": "^1.37.1", "@solendprotocol/solend-sdk": "^0.5.5", "@streamflow/stream": "3.0.10", "@switchboard-xyz/switchboard-v2": "^0.0.110", "@tippyjs/react": "^4.2.6", + "@urql/exchange-auth": "^1.0.0", + "@urql/exchange-graphcache": "^5.0.1", + "@urql/exchange-multipart-fetch": "^1.0.1", + "@urql/exchange-persisted-fetch": "^2.0.0", + "@urql/exchange-request-policy": "^1.0.0", "arweave": "^1.11.4", "auction-house-sdk": "^0.0.4", "axios": "^0.26.1", @@ -85,13 +100,19 @@ "buffer-layout": "^1.2.2", "classnames": "^2.3.1", "d3": "^7.4.4", + "date-fns": "^2.29.2", "dayjs": "^1.11.1", + "draft-js": "^0.11.7", "fp-ts": "^2.12.2", "goblingold-sdk": "^1.2.35", "graphql": "^16.5.0", "graphql-request": "^4.3.0", "immer": "^9.0.12", + "io-ts": "^2.2.18", + "io-ts-types": "^0.5.16", + "js-cookie": "^3.0.1", "libphonenumber-js": "^1.10.6", + "localforage": "^1.10.0", "next": "^12.2.2", "next-themes": "^0.1.1", "next-transpile-modules": "^8.0.0", @@ -114,9 +135,11 @@ "react-window": "^1.8.7", "remark-gfm": "^3.0.1", "superstruct": "^0.15.4", + "tailwind-merge": "^1.6.0", "ts-node": "^10.9.1", "tsconfig-paths": "^3.14.1", "tweetnacl": "^1.0.3", + "urql": "^3.0.3", "yup": "^0.32.11", "zustand": "^3.7.2" }, @@ -124,14 +147,18 @@ "@notifi-network/notifi-core": "^0.18.2", "@testing-library/jest-dom": "^5.16.4", "@testing-library/react": "^11.2.5", + "@types/carbon__icons-react": "^11.7.0", "@types/d3": "^7.4.0", "@types/jest": "^27.4.1", + "@types/js-cookie": "^3.0.2", "@types/node": "^14.14.25", "@types/react": "^17.0.44", "@typescript-eslint/eslint-plugin": "^5.30.6", "@typescript-eslint/parser": "^5.30.6", "eslint": "^8.14.0", "eslint-config-prettier": "^8.5.0", + "eslint-plugin-import": "^2.26.0", + "eslint-plugin-prettier": "^4.2.1", "eslint-plugin-react": "^7.29.4", "eslint-plugin-react-hooks": "^4.5.0", "husky": "^8.0.1", diff --git a/pages/_app.tsx b/pages/_app.tsx index d261fad650..8547af654f 100644 --- a/pages/_app.tsx +++ b/pages/_app.tsx @@ -1,218 +1,25 @@ -import { ThemeProvider } from 'next-themes' +import type { AppProps } from 'next/app' import '@dialectlabs/react-ui/index.css' -// import '../styles/ambit-font.css' -import '../styles/index.css' -import '../styles/typography.css' -import useWallet from '../hooks/useWallet' -import NavBar from '../components/NavBar' -import PageBodyContainer from '../components/PageBodyContainer' -import useHydrateStore from '../hooks/useHydrateStore' -import useRealm from '../hooks/useRealm' -import { getResourcePathPart } from '../tools/core/resources' -import handleRouterHistory from '@hooks/handleRouterHistory' -import { useEffect } from 'react' -import useDepositStore from 'VoteStakeRegistry/stores/useDepositStore' -import useWalletStore from 'stores/useWalletStore' -import { useVotingPlugins, vsrPluginsPks } from '@hooks/useVotingPlugins' -import ErrorBoundary from '@components/ErrorBoundary' -import { WalletIdentityProvider } from '@cardinal/namespaces-components' -import useVotePluginsClientStore from 'stores/useVotePluginsClientStore' -import useMarketStore from 'Strategies/store/marketStore' -import handleGovernanceAssetsStore from '@hooks/handleGovernanceAssetsStore' -import tokenService from '@utils/services/token' -import useGovernanceAssets from '@hooks/useGovernanceAssets' -import { usePrevious } from '@hooks/usePrevious' -import useTreasuryAccountStore from 'stores/useTreasuryAccountStore' -import useMembers from '@components/Members/useMembers' -import TransactionLoader from '@components/TransactionLoader' -import dynamic from 'next/dynamic' -import Head from 'next/head' -import { GatewayProvider } from '@components/Gateway/GatewayProvider' -import NftVotingCountingModal from '@components/NftVotingCountingModal' - -const Notifications = dynamic(() => import('../components/Notification'), { - ssr: false, -}) -function App({ Component, pageProps }) { - useHydrateStore() - useWallet() - handleRouterHistory() - useVotingPlugins() - handleGovernanceAssetsStore() - useMembers() - useEffect(() => { - tokenService.fetchSolanaTokenList() - }, []) - const { loadMarket } = useMarketStore() - const { governedTokenAccounts } = useGovernanceAssets() - const possibleNftsAccounts = governedTokenAccounts.filter( - (x) => x.isSol || x.isNft - ) - const { getNfts } = useTreasuryAccountStore() - const { getOwnedDeposits, resetDepositState } = useDepositStore() - const { realm, realmInfo, symbol, ownTokenRecord, config } = useRealm() - const wallet = useWalletStore((s) => s.current) - const connection = useWalletStore((s) => s.connection) - const client = useVotePluginsClientStore((s) => s.state.vsrClient) - const realmName = realmInfo?.displayName ?? realm?.account?.name - const prevStringifyPossibleNftsAccounts = usePrevious( - JSON.stringify(possibleNftsAccounts) - ) - const title = realmName ? `${realmName}` : 'Realms' +import { App as BaseApp } from '@components/App' +import { App as HubApp } from '@hub/App' - // Note: ?v==${Date.now()} is added to the url to force favicon refresh. - // Without it browsers would cache the last used and won't change it for different realms - // https://stackoverflow.com/questions/2208933/how-do-i-force-a-favicon-refresh - const faviconUrl = - symbol && - `/realms/${getResourcePathPart( - symbol as string - )}/favicon.ico?v=${Date.now()}` - useEffect(() => { - if (realm?.pubkey) { - loadMarket(connection, connection.cluster) - } - }, [connection.cluster, realm?.pubkey.toBase58()]) - useEffect(() => { - if ( - realm && - config?.account.communityTokenConfig.voterWeightAddin && - vsrPluginsPks.includes( - config.account.communityTokenConfig.voterWeightAddin.toBase58() - ) && - realm.pubkey && - wallet?.connected && - ownTokenRecord && - client - ) { - getOwnedDeposits({ - realmPk: realm!.pubkey, - communityMintPk: realm!.account.communityMint, - walletPk: ownTokenRecord!.account!.governingTokenOwner, - client: client!, - connection: connection.current, - }) - } else if (!wallet?.connected || !ownTokenRecord) { - resetDepositState() - } - }, [ - realm?.pubkey.toBase58(), - ownTokenRecord?.pubkey.toBase58(), - wallet?.connected, - client?.program.programId.toBase58(), - ]) +import '../styles/index.css' +import '../styles/typography.css' +import '@hub/components/controls/RichTextEditor/index.css' - useEffect(() => { - if ( - prevStringifyPossibleNftsAccounts !== - JSON.stringify(possibleNftsAccounts) && - realm?.pubkey - ) { - getNfts(possibleNftsAccounts, connection) - } - }, [JSON.stringify(possibleNftsAccounts), realm?.pubkey.toBase58()]) +export default function App({ Component, pageProps, router }: AppProps) { + if (router.pathname.startsWith('/realm/[id]')) { + return ( + + + + ) + } return ( -
- - - {title} - {faviconUrl ? ( - <> - - - ) : ( - <> - - - - - - - - - - - - - - - )} - - - - - - - - - - - - - - - - -
+ + + ) } - -export default App diff --git a/pages/_document.tsx b/pages/_document.tsx index 61f6df2e96..0276c4aa9d 100644 --- a/pages/_document.tsx +++ b/pages/_document.tsx @@ -1,41 +1,79 @@ -import { Html, Head, Main, NextScript } from 'next/document' +import Document, { + Html, + Head, + Main, + NextScript, + DocumentContext, + DocumentInitialProps, +} from 'next/document' -const Document = () => { - return ( - - - - - - - - - - - - - - - - - -
- - - - ) +import { getGraphqlJsonSchema } from '@hub/lib/getGraphqlJsonSchema' + +class RealmsDocument extends Document { + static async getInitialProps( + ctx: DocumentContext + ): Promise { + const originalRenderPage = ctx.renderPage + const schema = await getGraphqlJsonSchema() + + ctx.renderPage = () => + originalRenderPage({ + enhanceApp: (App) => App, + enhanceComponent: (Component) => Component, + }) + + const initialProps = await Document.getInitialProps(ctx) + + return { + ...initialProps, + head: [ + ...(initialProps.head || []), +