├── .env.example
├── .github
└── workflows
│ └── publish-to-npm.yaml
├── .gitignore
├── .npmignore
├── .storybook
├── main.ts
└── preview.tsx
├── LICENSE
├── README.md
├── package.json
├── src
├── components
│ ├── NeynarAuthButton
│ │ ├── icons
│ │ │ ├── FarcasterIcon.tsx
│ │ │ ├── PlanetBlackIcon.tsx
│ │ │ └── WarpcastIcon.tsx
│ │ └── index.tsx
│ ├── NeynarProfileCard
│ │ ├── components
│ │ │ └── ProfileCard.tsx
│ │ ├── hooks
│ │ │ └── useLinkifyBio.tsx
│ │ ├── icons
│ │ │ └── WarpcastPowerBadge.tsx
│ │ └── index.tsx
│ ├── index.tsx
│ ├── shared
│ │ ├── Avatar
│ │ │ └── index.tsx
│ │ ├── Box
│ │ │ └── index.tsx
│ │ ├── ButtonOutline
│ │ │ └── index.tsx
│ │ ├── ButtonPrimary
│ │ │ └── index.tsx
│ │ └── Toast
│ │ │ └── index.ts
│ └── stories
│ │ ├── NeynarAuthButton.stories.tsx
│ │ └── NeynarProfileCard.stories.tsx
├── constants.ts
├── contexts
│ ├── AuthContextProvider.tsx
│ ├── NeynarContextProvider.tsx
│ └── index.tsx
├── enums.ts
├── hooks
│ ├── index.ts
│ └── use-local-storage-state.ts
├── index.tsx
├── theme
│ └── index.ts
├── types
│ ├── common.ts
│ └── global.d.ts
└── utils
│ └── formatUtils.ts
├── tsconfig.json
├── vite.config.ts
└── yarn.lock
/.env.example:
--------------------------------------------------------------------------------
1 | CLIENT_ID="YOUR_CLIENT_ID"
2 | NEYNAR_LOGIN_URL="https://app.neynar.com/login"
3 |
--------------------------------------------------------------------------------
/.github/workflows/publish-to-npm.yaml:
--------------------------------------------------------------------------------
1 | name: Publish to npm 🚀
2 |
3 | on:
4 | push:
5 | branches:
6 | - main
7 |
8 | jobs:
9 | build:
10 | runs-on: ubuntu-latest
11 |
12 | steps:
13 | - name: Check out repository
14 | uses: actions/checkout@v2
15 |
16 | - name: Set up Node.js
17 | uses: actions/setup-node@v3
18 | with:
19 | node-version: '20'
20 |
21 | - name: Install dependencies
22 | run: yarn install
23 |
24 | - name: Build
25 | run: yarn build
26 |
27 | - name: Set npm Config
28 | run: npm config set //registry.npmjs.org/:_authToken=${{ secrets.NPM_TOKEN }}
29 |
30 | - name: Publish to npm
31 | run: npm publish --access public
32 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | /node_modules
2 | /dist
3 |
4 | test-sdk.ts
5 | *storybook.log
6 |
7 | .env
8 | .env.local
9 | .env.development
10 | .evn.production
--------------------------------------------------------------------------------
/.npmignore:
--------------------------------------------------------------------------------
1 | *.config.js
2 | src/
3 | .github/
4 | rollup.config.mjs
5 | package.json
6 | tsconfig.json
7 |
8 | .storybook
9 |
10 | .npmignore
--------------------------------------------------------------------------------
/.storybook/main.ts:
--------------------------------------------------------------------------------
1 | import type { StorybookConfig } from "@storybook/react-vite";
2 |
3 | const config: StorybookConfig = {
4 | stories: ["../src/**/*.mdx", "../src/**/*.stories.@(js|jsx|mjs|ts|tsx)"],
5 | addons: [
6 | "@storybook/addon-onboarding",
7 | "@storybook/addon-links",
8 | "@storybook/addon-essentials",
9 | "@chromatic-com/storybook",
10 | "@storybook/addon-interactions",
11 | '@storybook/addon-themes',
12 | ],
13 | framework: {
14 | name: "@storybook/react-vite",
15 | options: {},
16 | },
17 | docs: {
18 | autodocs: "tag",
19 | },
20 | };
21 | export default config;
22 |
--------------------------------------------------------------------------------
/.storybook/preview.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import type { Preview, Decorator } from "@storybook/react";
3 | import { withThemeByClassName } from "@storybook/addon-themes";
4 | import { NeynarContextProvider } from "../src/contexts/NeynarContextProvider";
5 | import { Theme } from "../src/enums";
6 |
7 | import "../dist/style.css";
8 |
9 | const themeDecorator = withThemeByClassName({
10 | defaultTheme: Theme.Light,
11 | themes: {
12 | light: "theme-light",
13 | dark: "theme-dark",
14 | },
15 | });
16 |
17 | const withNeynarProvider: Decorator = (Story, context) => {
18 | const theme = context.globals.theme || Theme.Light;
19 |
20 | return (
21 |
35 |
36 |
37 | );
38 | };
39 |
40 | const preview: Preview = {
41 | decorators: [themeDecorator, withNeynarProvider],
42 | parameters: {
43 | controls: {
44 | matchers: {
45 | color: /(background|color)$/i,
46 | date: /Date$/i,
47 | },
48 | },
49 | },
50 | };
51 |
52 | export default preview;
53 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2024 Neynar
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # @neynar/react
2 |
3 | ## Introduction
4 |
5 | `@neynar/react` is the official Frontend SDK from [Neynar](https://neynar.com/). This SDK includes React components to build Farcaster clients.
6 |
7 | ## Peer dependencies
8 |
9 | Please make sure that the following dependencies are already a part of your project.
10 |
11 | ```json
12 | {
13 | "react": "^18.3.0",
14 | "react-dom": "^18.3.0",
15 | "@pigment-css/react": "^0.0.9"
16 | }
17 | ```
18 |
19 | ## Installation
20 |
21 | - For yarn
22 |
23 | ```bash
24 | yarn add @neynar/react
25 | ```
26 |
27 | - For npm
28 |
29 | ```bash
30 | npm install @neynar/react
31 | ```
32 |
33 | ## Example app
34 |
35 | Check out our [example app](https://github.com/neynarxyz/farcaster-examples/tree/main/wownar-react-sdk) for a demonstration of how to use `@neynar/react`.
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@neynar/react",
3 | "version": "0.4.2",
4 | "description": "Farcaster frontend component library powered by Neynar",
5 | "main": "dist/bundle.cjs.js",
6 | "module": "dist/bundle.es.js",
7 | "types": "dist/index.d.ts",
8 | "author": "Neynar",
9 | "license": "MIT",
10 | "scripts": {
11 | "build": "tsc && vite build",
12 | "storybook": "storybook dev -p 6006",
13 | "build-storybook": "storybook build"
14 | },
15 | "eslintConfig": {
16 | "extends": [
17 | "react-app",
18 | "plugin:storybook/recommended"
19 | ]
20 | },
21 | "peerDependencies": {
22 | "@pigment-css/react": "^0.0.9",
23 | "react": "^18.3.0",
24 | "react-dom": "^18.3.0"
25 | },
26 | "devDependencies": {
27 | "@babel/core": "^7.24.4",
28 | "@babel/preset-env": "^7.24.4",
29 | "@babel/preset-react": "^7.24.1",
30 | "@chromatic-com/storybook": "^1.3.3",
31 | "@pigment-css/react": "^0.0.9",
32 | "@pigment-css/vite-plugin": "^0.0.9",
33 | "@storybook/addon-essentials": "^8.0.9",
34 | "@storybook/addon-interactions": "^8.0.9",
35 | "@storybook/addon-links": "^8.0.9",
36 | "@storybook/addon-onboarding": "^8.0.9",
37 | "@storybook/addon-themes": "^8.1.2",
38 | "@storybook/blocks": "^8.0.9",
39 | "@storybook/react": "^8.0.9",
40 | "@storybook/react-vite": "^8.0.9",
41 | "@storybook/test": "^8.0.9",
42 | "@types/react": "^18.3.0",
43 | "@types/react-dom": "^18.3.0",
44 | "@vitejs/plugin-react": "^4.2.1",
45 | "axios": "^1.6.8",
46 | "dotenv": "^16.4.5",
47 | "eslint-plugin-storybook": "^0.8.0",
48 | "storybook": "^8.0.9",
49 | "typescript": "^5.4.5",
50 | "vite": "^5.2.10",
51 | "vite-plugin-dts": "^3.9.0",
52 | "vite-tsconfig-paths": "^4.3.2"
53 | }
54 | }
55 |
--------------------------------------------------------------------------------
/src/components/NeynarAuthButton/icons/FarcasterIcon.tsx:
--------------------------------------------------------------------------------
1 | export const FarcasterIcon = () => {
2 | return (
3 |
24 | );
25 | };
26 |
--------------------------------------------------------------------------------
/src/components/NeynarAuthButton/icons/PlanetBlackIcon.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 |
3 | const PlanetBlackIcon = () => (
4 |
38 | );
39 |
40 | export default PlanetBlackIcon;
41 |
--------------------------------------------------------------------------------
/src/components/NeynarAuthButton/icons/WarpcastIcon.tsx:
--------------------------------------------------------------------------------
1 | export const WarpcastIcon = () => {
2 | return (
3 |
26 | );
27 | };
28 |
--------------------------------------------------------------------------------
/src/components/NeynarAuthButton/index.tsx:
--------------------------------------------------------------------------------
1 | import React, { useCallback, useEffect, useState, useRef } from "react";
2 | import { styled } from "@pigment-css/react";
3 | import PlanetBlackIcon from "./icons/PlanetBlackIcon";
4 | import { useNeynarContext } from "../../contexts";
5 | import { useAuth } from "../../contexts/AuthContextProvider";
6 | import { useLocalStorage } from "../../hooks";
7 | import { LocalStorageKeys } from "../../hooks/use-local-storage-state";
8 | import { INeynarAuthenticatedUser } from "../../types/common";
9 | import { SIWN_variant } from "../../enums";
10 | import { FarcasterIcon } from "./icons/FarcasterIcon";
11 | import { WarpcastIcon } from "./icons/WarpcastIcon";
12 |
13 | interface ButtonProps extends React.ButtonHTMLAttributes {
14 | label?: string;
15 | icon?: React.ReactNode;
16 | variant?: SIWN_variant;
17 | modalStyle?: React.CSSProperties;
18 | modalButtonStyle?: React.CSSProperties;
19 | }
20 |
21 | const Img = styled.img({
22 | width: "20px",
23 | height: "20px",
24 | borderRadius: "50%",
25 | });
26 |
27 | const Button = styled.button((props) => ({
28 | backgroundColor: "#ffffff",
29 | border: "none",
30 | color: "#000000",
31 | padding: "15px",
32 | fontSize: "15px",
33 | fontWeight: "600",
34 | lineHeight: "18.9px",
35 | borderRadius: "100px",
36 | display: "flex",
37 | alignItems: "center",
38 | justifyContent: "center",
39 | cursor: "pointer",
40 | textDecoration: "none",
41 | boxShadow: "0 2px 4px rgba(0, 0, 0, 0.2)",
42 | transition: "background-color 0.3s",
43 | }));
44 |
45 | const Modal = styled.div((props) => ({
46 | position: "fixed",
47 | top: "50%",
48 | left: "50%",
49 | transform: "translate(-50%, -50%)",
50 | width: "300px",
51 | padding: "20px",
52 | display: "flex",
53 | flexDirection: "column",
54 | justifyContent: "space-around",
55 | rowGap: "20px",
56 | alignItems: "center",
57 | backgroundColor: "#fff",
58 | borderRadius: "15px",
59 | boxShadow: "0 4px 8px rgba(0, 0, 0, 0.1)",
60 | zIndex: "1000",
61 | fontFamily: props.theme.typography.fonts.base,
62 | fontSize: props.theme.typography.fontSizes.medium,
63 | "> img": {
64 | width: "80px",
65 | height: "80px",
66 | borderRadius: "50%",
67 | },
68 | "> span": {
69 | color: "#000",
70 | fontWeight: "bold",
71 | },
72 | }));
73 |
74 | const ModalButton = styled.button({
75 | width: "100%",
76 | padding: "10px 0",
77 | backgroundColor: "#ffffff",
78 | color: "#000000",
79 | border: "1px solid #e0e0e0",
80 | borderRadius: "8px",
81 | fontWeight: "bold",
82 | cursor: "pointer",
83 | transition: "background-color 0.2s",
84 | "&:hover": {
85 | boxShadow: "0 2px 3px rgba(0, 0, 0, 0.1)",
86 | },
87 | });
88 |
89 | export const NeynarAuthButton: React.FC = ({
90 | children,
91 | label = "Sign in with Neynar",
92 | icon = ,
93 | variant = SIWN_variant.NEYNAR,
94 | modalStyle = {},
95 | modalButtonStyle = {},
96 | ...rest
97 | }) => {
98 | const { client_id, user, isAuthenticated } = useNeynarContext();
99 | const { setIsAuthenticated, setUser, onAuthSuccess, onSignout } = useAuth();
100 | const [_, setNeynarAuthenticatedUser, removeNeynarAuthenticatedUser] =
101 | useLocalStorage(
102 | LocalStorageKeys.NEYNAR_AUTHENTICATED_USER
103 | );
104 | const [showModal, setShowModal] = useState(false);
105 |
106 | // Using useRef to store the authWindow reference
107 | const authWindowRef = useRef(null);
108 | const neynarLoginUrl = `${process.env.NEYNAR_LOGIN_URL ?? "https://app.neynar.com/login"}?client_id=${client_id}`;
109 | const authOrigin = new URL(neynarLoginUrl).origin;
110 |
111 | const modalRef = useRef(null);
112 |
113 | const handleMessage = useCallback(
114 | async (event: MessageEvent) => {
115 | if (
116 | event.origin === authOrigin &&
117 | event.data &&
118 | event.data.is_authenticated
119 | ) {
120 | setIsAuthenticated(true);
121 | authWindowRef.current?.close();
122 | window.removeEventListener("message", handleMessage); // Remove listener here
123 | const _user = {
124 | signer_uuid: event.data.signer_uuid,
125 | ...event.data.user,
126 | };
127 | setNeynarAuthenticatedUser(_user);
128 | setUser(_user);
129 | onAuthSuccess({ user: _user });
130 | }
131 | },
132 | [client_id, setIsAuthenticated]
133 | );
134 |
135 | const handleSignIn = useCallback(() => {
136 | const width = 600,
137 | height = 700;
138 | const left = window.screen.width / 2 - width / 2;
139 | const top = window.screen.height / 2 - height / 2;
140 | const windowFeatures = `width=${width},height=${height},top=${top},left=${left}`;
141 |
142 | authWindowRef.current = window.open(
143 | neynarLoginUrl,
144 | "_blank",
145 | windowFeatures
146 | );
147 |
148 | if (!authWindowRef.current) {
149 | console.error(
150 | "Failed to open the authentication window. Please check your pop-up blocker settings."
151 | );
152 | return;
153 | }
154 |
155 | window.addEventListener("message", handleMessage, false);
156 | }, [client_id, handleMessage]);
157 |
158 | const handleSignOut = () => {
159 | if (user) {
160 | const _user = user;
161 | removeNeynarAuthenticatedUser();
162 | setIsAuthenticated(false);
163 | closeModal();
164 | const { signer_uuid, ...rest } = _user;
165 | onSignout(rest);
166 | }
167 | };
168 |
169 | const openModal = () => setShowModal(true);
170 | const closeModal = () => setShowModal(false);
171 |
172 | useEffect(() => {
173 | return () => {
174 | window.removeEventListener("message", handleMessage); // Cleanup function to remove listener
175 | };
176 | }, [handleMessage]);
177 |
178 | const handleOutsideClick = useCallback((event: any) => {
179 | if (modalRef.current && !modalRef.current.contains(event.target)) {
180 | closeModal();
181 | }
182 | }, []);
183 |
184 | useEffect(() => {
185 | if (showModal) {
186 | document.addEventListener("mousedown", handleOutsideClick);
187 | } else {
188 | document.removeEventListener("mousedown", handleOutsideClick);
189 | }
190 |
191 | return () => {
192 | document.removeEventListener("mousedown", handleOutsideClick);
193 | };
194 | }, [showModal, handleOutsideClick]);
195 |
196 | const getLabel = () => {
197 | switch (variant) {
198 | case SIWN_variant.FARCASTER:
199 | return "Sign in with Farcaster";
200 | case SIWN_variant.NEYNAR:
201 | return "Sign in with Neynar";
202 | case SIWN_variant.WARPCAST:
203 | return "Sign in with Warpcast";
204 | default:
205 | return "Sign in with Neynar";
206 | }
207 | };
208 |
209 | const getIcon = () => {
210 | switch (variant) {
211 | case SIWN_variant.FARCASTER:
212 | return ;
213 | case SIWN_variant.NEYNAR:
214 | return ;
215 | case SIWN_variant.WARPCAST:
216 | return ;
217 | default:
218 | return ;
219 | }
220 | };
221 |
222 | return (
223 | <>
224 | {showModal && (
225 |
226 |
227 | @{user?.username}
228 |
229 | Sign out
230 |
231 |
232 | )}
233 |
254 | >
255 | );
256 | };
257 |
--------------------------------------------------------------------------------
/src/components/NeynarProfileCard/components/ProfileCard.tsx:
--------------------------------------------------------------------------------
1 | import { useMemo, memo } from "react";
2 | import { styled } from "@pigment-css/react";
3 | import { Box, HBox, VBox } from "../../shared/Box";
4 | import { formatToReadableNumber } from "../../../utils/formatUtils";
5 | import { useLinkifyBio } from "../hooks/useLinkifyBio";
6 | import { WarpcastPowerBadge } from "../icons/WarpcastPowerBadge";
7 | import ButtonOutline from "../../shared/ButtonOutline";
8 | import ButtonPrimary from "../../shared/ButtonPrimary";
9 | import Avatar from "../../shared/Avatar";
10 |
11 | const StyledProfileCard = styled.div(({ theme }) => ({
12 | display: "flex",
13 | flexDirection: "column",
14 | width: "100%",
15 | maxWidth: "608px",
16 | borderWidth: "1px",
17 | borderStyle: "solid",
18 | borderColor: theme.vars.palette.border,
19 | borderRadius: "15px",
20 | padding: "30px",
21 | color: theme.vars.palette.text,
22 | fontFamily: theme.typography.fonts.base,
23 | fontSize: theme.typography.fontSizes.medium,
24 | backgroundColor: theme.vars.palette.background,
25 | }));
26 |
27 | const Main = styled.div(() => ({
28 | display: "flex",
29 | flexDirection: "column",
30 | justifyContent: "space-between",
31 | flex: 1,
32 | }));
33 |
34 | const Username = styled.div(({ theme }) => ({
35 | color: theme.vars.palette.textMuted,
36 | }));
37 |
38 | const UsernameTitle = styled.div(({ theme }) => ({
39 | fontSize: theme.typography.fontSizes.large,
40 | fontWeight: theme.typography.fontWeights.bold,
41 | }));
42 |
43 | const ProfileMetaCell = styled.div(({ theme }) => ({
44 | color: theme.vars.palette.textMuted,
45 | "> strong": {
46 | color: theme.vars.palette.text,
47 | },
48 | "& + &": {
49 | marginLeft: "15px",
50 | },
51 | }));
52 |
53 | const Tag = styled.div(({ theme }) => ({
54 | borderWidth: "1px",
55 | borderStyle: "solid",
56 | borderColor: theme.vars.palette.border,
57 | borderRadius: "5px",
58 | padding: "3px 6px",
59 | marginTop: "3px",
60 | marginLeft: "5px",
61 | backgroundColor: "transparent",
62 | fontSize: theme.typography.fontSizes.small,
63 | color: theme.vars.palette.textMuted,
64 | lineHeight: 1,
65 | }));
66 |
67 | export type ProfileCardProps = {
68 | username: string;
69 | displayName: string;
70 | avatarImgUrl: string;
71 | bio: string;
72 | followers: number;
73 | following: number;
74 | hasPowerBadge: boolean;
75 | isFollowing?: boolean;
76 | isOwnProfile?: boolean;
77 | onCast?: () => void;
78 | };
79 |
80 | export const ProfileCard = memo(
81 | ({
82 | username,
83 | displayName,
84 | avatarImgUrl,
85 | bio,
86 | followers,
87 | following,
88 | hasPowerBadge,
89 | isFollowing,
90 | isOwnProfile,
91 | onCast,
92 | }: ProfileCardProps) => {
93 | const linkifiedBio = useLinkifyBio(bio);
94 |
95 | const formattedFollowingCount = useMemo(
96 | () => formatToReadableNumber(following),
97 | [following]
98 | );
99 |
100 | const formattedFollowersCount = useMemo(
101 | () => formatToReadableNumber(followers),
102 | [followers]
103 | );
104 |
105 | const handleEditProfile = () => {
106 | window.open("https://warpcast.com/~/settings", "_blank");
107 | };
108 |
109 | return (
110 |
111 | {isOwnProfile && onCast && (
112 |
117 | @{username}
118 | Cast
119 |
120 | )}
121 |
122 |
123 |
128 |
129 |
130 |
131 |
132 |
133 | {displayName}
134 | {hasPowerBadge && (
135 |
136 |
137 |
138 | )}
139 |
140 |
141 | @{username}
142 | {isFollowing && Follows you}
143 |
144 |
145 |
146 | {isOwnProfile && (
147 |
148 | Edit Profile
149 |
150 | )}
151 |
152 |
153 |
154 |
155 | {linkifiedBio}
156 |
157 |
158 |
159 |
160 | {formattedFollowingCount} Following
161 |
162 |
163 | {formattedFollowersCount} Followers
164 |
165 |
166 |
167 |
168 |
169 | );
170 | }
171 | );
172 |
--------------------------------------------------------------------------------
/src/components/NeynarProfileCard/hooks/useLinkifyBio.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { styled } from "@pigment-css/react";
3 |
4 | const WARPCAST_DOMAIN = "https://warpcast.com";
5 |
6 | const channelRegex = /\/\w+/g;
7 | const mentionRegex = /@\w+/g;
8 | const urlRegex = /((https?:\/\/)?([a-zA-Z0-9.-]+\.[a-zA-Z]{2,})(\/[^\s]*)?)/g;
9 | const combinedRegex = new RegExp(
10 | `(${channelRegex.source})|(${mentionRegex.source})|(${urlRegex.source})`,
11 | "g"
12 | );
13 |
14 | const generateUrl = (match: string): string => {
15 | if (channelRegex.test(match)) {
16 | return `${WARPCAST_DOMAIN}/~/channel${match}`;
17 | } else if (mentionRegex.test(match)) {
18 | return `${WARPCAST_DOMAIN}/${match.substring(1)}`;
19 | } else if (urlRegex.test(match)) {
20 | return match.startsWith("http") ? match : `http://${match}`;
21 | }
22 | return "";
23 | };
24 |
25 | const StyledLink = styled.a(({ theme }) => ({
26 | textDecoration: "underline",
27 | color: theme.vars.colors.primary,
28 | }));
29 |
30 | export const useLinkifyBio = (text: string): React.ReactNode[] => {
31 | const elements: React.ReactNode[] = [];
32 | let lastIndex = 0;
33 |
34 | let match;
35 | while ((match = combinedRegex.exec(text)) !== null) {
36 | const matchIndex = match.index;
37 | if (lastIndex < matchIndex) {
38 | elements.push(text.slice(lastIndex, matchIndex));
39 | }
40 |
41 | const url = generateUrl(match[0]);
42 | elements.push(
43 |
44 | {match[0]}
45 |
46 | );
47 |
48 | lastIndex = combinedRegex.lastIndex;
49 | }
50 |
51 | if (lastIndex < text.length) {
52 | elements.push(text.slice(lastIndex));
53 | }
54 |
55 | return elements;
56 | };
57 |
--------------------------------------------------------------------------------
/src/components/NeynarProfileCard/icons/WarpcastPowerBadge.tsx:
--------------------------------------------------------------------------------
1 | export const WarpcastPowerBadge = () => {
2 | return (
3 |
7 | );
8 | };
9 |
--------------------------------------------------------------------------------
/src/components/NeynarProfileCard/index.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState, useEffect, useCallback } from "react";
2 | import { ProfileCard } from "./components/ProfileCard";
3 | import { useNeynarContext } from "../../contexts";
4 | import { NEYNAR_API_URL } from "../../constants";
5 |
6 | async function fetchUserByFid({
7 | fid,
8 | viewerFid,
9 | clientId,
10 | }: {
11 | fid: number;
12 | viewerFid?: number;
13 | clientId: string;
14 | }): Promise {
15 | try {
16 | let url = `${NEYNAR_API_URL}/v2/farcaster/user/bulk?client_id=${clientId}&fids=${fid}`;
17 |
18 | if (viewerFid) {
19 | url += `&viewer_fid=${viewerFid}`;
20 | }
21 |
22 | const response = await fetch(url);
23 | const data = await response.json();
24 | return data?.users?.[0] ?? null;
25 | } catch (error) {
26 | console.error("Error fetching user by fid", error);
27 | return null;
28 | }
29 | }
30 |
31 | export type NeynarProfileCardProps = {
32 | fid: number;
33 | viewerFid?: number;
34 | };
35 |
36 | export const NeynarProfileCard: React.FC = ({
37 | fid,
38 | viewerFid,
39 | }) => {
40 | const { client_id } = useNeynarContext();
41 |
42 | const [userData, setUserData] = useState(null);
43 | const [loading, setLoading] = useState(true);
44 | const [error, setError] = useState(null);
45 |
46 | const isOwnProfile = userData?.fid === viewerFid;
47 |
48 | useEffect(() => {
49 | if (fid) {
50 | setLoading(true);
51 | setError(null);
52 |
53 | fetchUserByFid({ fid, viewerFid, clientId: client_id })
54 | .then((data) => {
55 | setUserData(data);
56 | })
57 | .catch((error) => {
58 | setError(error);
59 | })
60 | .finally(() => {
61 | setLoading(false);
62 | });
63 | }
64 | }, [fid, viewerFid]);
65 |
66 | const handleCast = useCallback(() => {
67 | // TODO: Handle cast
68 | }, []);
69 |
70 | if (loading) {
71 | return Loading...
;
72 | }
73 |
74 | if (!userData || error) {
75 | return Error fetching user data
;
76 | }
77 |
78 | return (
79 |
91 | );
92 | };
93 |
--------------------------------------------------------------------------------
/src/components/index.tsx:
--------------------------------------------------------------------------------
1 | export { NeynarAuthButton } from "./NeynarAuthButton";
2 | export { NeynarProfileCard } from "./NeynarProfileCard";
3 |
--------------------------------------------------------------------------------
/src/components/shared/Avatar/index.tsx:
--------------------------------------------------------------------------------
1 | import { styled } from "@pigment-css/react";
2 |
3 | const Avatar = styled.img(() => ({
4 | width: "45px",
5 | height: "45px",
6 | borderRadius: "50%",
7 | aspectRatio: 1 / 1,
8 | objectFit: "cover",
9 | }));
10 |
11 | export default Avatar;
12 |
--------------------------------------------------------------------------------
/src/components/shared/Box/index.tsx:
--------------------------------------------------------------------------------
1 | import { HTMLAttributes } from "react";
2 | import { styled } from "@pigment-css/react";
3 |
4 | interface BoxProps extends HTMLAttributes {
5 | alignItems?: "center" | "flex-start" | "flex-end";
6 | justifyContent?:
7 | | "center"
8 | | "flex-start"
9 | | "flex-end"
10 | | "space-between"
11 | | "space-around"
12 | | "space-evenly";
13 | flexGrow?: number;
14 | flexShrink?: number;
15 | spacing?: string;
16 | spacingTop?: string;
17 | spacingRight?: string;
18 | spacingBottom?: string;
19 | spacingLeft?: string;
20 | spacingVertical?: string;
21 | spacingHorizontal?: string;
22 | }
23 |
24 | export const Box = styled.div({
25 | display: "flex",
26 | alignItems: (props) => props.alignItems || "flex-start",
27 | justifyContent: (props) => props.justifyContent || "flex-start",
28 | flexGrow: (props) => props.flexGrow || "initial",
29 | flexShrink: (props) => props.flexShrink || "initial",
30 | marginTop: (props) =>
31 | props.spacing ?? props.spacingVertical ?? props.spacingTop ?? "0px",
32 | marginRight: (props) =>
33 | props.spacing ?? props.spacingHorizontal ?? props.spacingRight ?? "0px",
34 | marginBottom: (props) =>
35 | props.spacing ?? props.spacingVertical ?? props.spacingBottom ?? "0px",
36 | marginLeft: (props) =>
37 | props.spacing ?? props.spacingHorizontal ?? props.spacingLeft ?? "0px",
38 | });
39 |
40 | export const VBox = styled(Box)({
41 | flexDirection: "column",
42 | });
43 |
44 | export const HBox = styled(Box)({
45 | flexDirection: "row",
46 | });
47 |
--------------------------------------------------------------------------------
/src/components/shared/ButtonOutline/index.tsx:
--------------------------------------------------------------------------------
1 | import { styled } from "@pigment-css/react";
2 |
3 | const ButtonOutline = styled.button(({ theme }) => ({
4 | borderWidth: "1px",
5 | borderStyle: "solid",
6 | borderColor: theme.vars.palette.border,
7 | borderRadius: "7px",
8 | padding: "10px",
9 | backgroundColor: "transparent",
10 | color: theme.vars.palette.text,
11 | fontWeight: theme.typography.fontWeights.bold,
12 | lineHeight: 1,
13 | cursor: "pointer",
14 | "& + &": {
15 | marginLeft: "10px",
16 | },
17 | }));
18 |
19 | export default ButtonOutline;
20 |
--------------------------------------------------------------------------------
/src/components/shared/ButtonPrimary/index.tsx:
--------------------------------------------------------------------------------
1 | import { styled } from "@pigment-css/react";
2 |
3 | const ButtonPrimary = styled.button(({ theme }) => ({
4 | border: "none",
5 | borderRadius: "7px",
6 | padding: "13px 15px",
7 | backgroundColor: theme.colors.primary,
8 | color: "#fff",
9 | fontWeight: theme.typography.fontWeights.bold,
10 | lineHeight: 1,
11 | cursor: "pointer",
12 | "& + &": {
13 | marginLeft: "10px",
14 | },
15 | }));
16 |
17 | export default ButtonPrimary;
18 |
--------------------------------------------------------------------------------
/src/components/shared/Toast/index.ts:
--------------------------------------------------------------------------------
1 | import { styled, keyframes } from "@pigment-css/react";
2 |
3 | const fadeInOut = keyframes`
4 | 0% {
5 | opacity: 0;
6 | }
7 | 10% {
8 | opacity: 1;
9 | }
10 | 90% {
11 | opacity: 1;
12 | }
13 | 100% {
14 | opacity: 1;
15 | }
16 | `;
17 |
18 | // Styled component for the toast container
19 | export const ToastContainer = styled.div`
20 | position: fixed;
21 | bottom: 20px;
22 | right: 20px;
23 | z-index: 9999;
24 | `;
25 |
26 | export const ToastItem = styled("div")<{ type: string }>((props) => ({
27 | padding: "10px 20px",
28 | marginBottom: "10px",
29 | borderRadius: "5px",
30 | color: "#fff",
31 | animation: `${fadeInOut} 4s ease-out`,
32 | fontFamily: props.theme.typography.fonts.base,
33 | fontSize: props.theme.typography.fontSizes.medium,
34 | variants: [
35 | {
36 | props: { type: "success" },
37 | style: {
38 | backgroundColor: "#32cd32",
39 | },
40 | },
41 | {
42 | props: { type: "error" },
43 | style: {
44 | backgroundColor: "#ff6347",
45 | },
46 | },
47 | {
48 | props: { type: "warning" },
49 | style: {
50 | backgroundColor: "#ffa500",
51 | },
52 | },
53 | {
54 | props: { type: "info" },
55 | style: {
56 | backgroundColor: "#3498db",
57 | },
58 | },
59 | ],
60 | }));
61 |
62 | export enum ToastType {
63 | Success = "success",
64 | Error = "error",
65 | Warning = "warning",
66 | Info = "info",
67 | }
68 |
--------------------------------------------------------------------------------
/src/components/stories/NeynarAuthButton.stories.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { Meta, StoryFn } from "@storybook/react";
3 | import { NeynarAuthButton } from "../NeynarAuthButton/index";
4 | import { SIWN_variant } from "../../enums";
5 |
6 | export default {
7 | title: "NeynarAuthButton",
8 | component: NeynarAuthButton,
9 | } as Meta;
10 |
11 | const Template: StoryFn = (args) => ;
12 |
13 | export const Primary = Template.bind({});
14 | Primary.args = {
15 | primary: true,
16 | label: "Sign in with Neynar",
17 | variant: SIWN_variant.FARCASTER,
18 | };
19 |
--------------------------------------------------------------------------------
/src/components/stories/NeynarProfileCard.stories.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { Meta, StoryFn } from "@storybook/react";
3 | import { ProfileCard, ProfileCardProps } from "../NeynarProfileCard/components/ProfileCard";
4 | import { NeynarProfileCard, NeynarProfileCardProps } from "../NeynarProfileCard/index";
5 |
6 | const meta: Meta = {
7 | title: "NeynarProfileCard",
8 | component: NeynarProfileCard,
9 | };
10 |
11 | export default meta;
12 |
13 | const Template: StoryFn = (args) => ;
14 | const TemplateWithUser: StoryFn = ({ fid, viewerFid }) => (
15 |
16 | );
17 |
18 | export const Primary = Template.bind({});
19 | Primary.args = {
20 | username: "rish",
21 | displayName: "rish",
22 | avatarImgUrl: "https://i.imgur.com/naZWL9n.gif",
23 | bio: "building /neynar 🪐 | neynar.com | /rish",
24 | followers: 127364,
25 | following: 676,
26 | hasPowerBadge: true,
27 | isOwnProfile: true,
28 | isFollowing: true,
29 | onCast: () => {},
30 | };
31 | Primary.argTypes = {
32 | fid: { table: { disable: true } },
33 | viewerFid: { table: { disable: true } },
34 | onCast: { table: { disable: true } },
35 | };
36 |
37 | export const WithUser = TemplateWithUser.bind({});
38 | WithUser.args = {
39 | fid: 1,
40 | viewerFid: 1,
41 | };
42 |
--------------------------------------------------------------------------------
/src/constants.ts:
--------------------------------------------------------------------------------
1 | export const NEYNAR_API_URL = "https://sdk-api.neynar.com";
--------------------------------------------------------------------------------
/src/contexts/AuthContextProvider.tsx:
--------------------------------------------------------------------------------
1 | import React, {
2 | createContext,
3 | useContext,
4 | useState,
5 | ReactNode,
6 | useMemo,
7 | useEffect,
8 | } from "react";
9 | import { INeynarAuthenticatedUser, IUser, SetState } from "../types/common";
10 | import { useLocalStorage } from "../hooks";
11 | import { LocalStorageKeys } from "../hooks/use-local-storage-state";
12 | import { useNeynarContext } from "./NeynarContextProvider";
13 |
14 | interface IAuthContext {
15 | isAuthenticated: boolean;
16 | setIsAuthenticated: SetState;
17 | user: INeynarAuthenticatedUser | null;
18 | setUser: SetState;
19 | onAuthSuccess: (params: { user: INeynarAuthenticatedUser }) => void;
20 | onSignout: (user: IUser | undefined) => void;
21 | }
22 |
23 | const AuthContext = createContext(undefined);
24 |
25 | export interface AuthContextProviderProps {
26 | children: ReactNode;
27 | _setIsAuthenticated: (_isAuthenticated: boolean) => void;
28 | _setUser: (_user: INeynarAuthenticatedUser | null) => void;
29 | _onAuthSuccess?: (params: { user: INeynarAuthenticatedUser }) => void;
30 | _onSignout?: (user: IUser | undefined) => void;
31 | }
32 |
33 | export const AuthContextProvider: React.FC = ({
34 | children,
35 | _setIsAuthenticated,
36 | _setUser,
37 | _onAuthSuccess,
38 | _onSignout,
39 | }) => {
40 | const { isAuthenticated: _isAuthenticated } = useNeynarContext();
41 |
42 | const [isAuthenticated, setIsAuthenticated] = useState(false);
43 | const [user, setUser] = useState(null);
44 | const [neynarAuthenticatedUser] = useLocalStorage(
45 | LocalStorageKeys.NEYNAR_AUTHENTICATED_USER
46 | );
47 |
48 | useEffect(() => {
49 | _setIsAuthenticated(isAuthenticated);
50 | }, [isAuthenticated]);
51 |
52 | useEffect(() => {
53 | setIsAuthenticated(_isAuthenticated);
54 | }, [_isAuthenticated]);
55 |
56 | useEffect(() => {
57 | if (neynarAuthenticatedUser) {
58 | setUser(neynarAuthenticatedUser);
59 | setIsAuthenticated(true);
60 | } else {
61 | setUser(null);
62 | setIsAuthenticated(false);
63 | }
64 | }, []);
65 |
66 | useEffect(() => {
67 | _setUser(user);
68 | }, [user]);
69 |
70 | const onAuthSuccess = (params: { user: INeynarAuthenticatedUser }) => {
71 | _onAuthSuccess && _onAuthSuccess(params);
72 | };
73 |
74 | const onSignout = (user: IUser | undefined) => {
75 | _onSignout && _onSignout(user);
76 | };
77 |
78 | const value = useMemo(
79 | () => ({
80 | isAuthenticated,
81 | user,
82 | setIsAuthenticated,
83 | setUser,
84 | onAuthSuccess,
85 | onSignout,
86 | }),
87 | [isAuthenticated, user]
88 | );
89 |
90 | return {children};
91 | };
92 |
93 | export const useAuth = () => {
94 | const context = useContext(AuthContext);
95 | if (!context) {
96 | throw new Error("useAuth must be used within a AuthContextProvider");
97 | }
98 | return context;
99 | };
100 |
--------------------------------------------------------------------------------
/src/contexts/NeynarContextProvider.tsx:
--------------------------------------------------------------------------------
1 | import React, {
2 | createContext,
3 | useContext,
4 | useState,
5 | ReactNode,
6 | useMemo,
7 | useEffect,
8 | } from "react";
9 | import { Theme } from "../enums";
10 | import { INeynarAuthenticatedUser, IUser, SetState } from "../types/common";
11 | import { AuthContextProvider } from "./AuthContextProvider";
12 | import {
13 | ToastContainer,
14 | ToastItem,
15 | ToastType,
16 | } from "../components/shared/Toast";
17 | import { LocalStorageKeys } from "../hooks/use-local-storage-state";
18 |
19 | interface INeynarContext {
20 | client_id: string;
21 | theme: Theme;
22 | setTheme: SetState;
23 | isAuthenticated: boolean;
24 | showToast: (type: ToastType, message: string) => void;
25 | user: INeynarAuthenticatedUser | null;
26 | logoutUser: () => void;
27 | }
28 |
29 | const NeynarContext = createContext(undefined);
30 |
31 | export interface NeynarContextProviderProps {
32 | children: ReactNode;
33 | settings: {
34 | clientId: string;
35 | defaultTheme?: Theme;
36 | eventsCallbacks?: {
37 | onAuthSuccess?: (params: { user: INeynarAuthenticatedUser }) => void;
38 | onSignout?: (user: IUser | undefined) => void;
39 | };
40 | };
41 | }
42 |
43 | export const NeynarContextProvider: React.FC = ({
44 | children,
45 | settings: { clientId, defaultTheme = Theme.Light, eventsCallbacks },
46 | }) => {
47 | const [client_id] = useState(clientId);
48 | const [isAuthenticated, setIsAuthenticated] = useState(false);
49 | const [theme, setTheme] = useState(defaultTheme);
50 | const [toasts, setToasts] = useState<{ type: string; message: string }[]>([]);
51 | const [user, setUser] = useState(null);
52 |
53 | const showToast = (type: ToastType, message: string) => {
54 | const newToast = { type, message };
55 | setToasts((prevToasts) => [...prevToasts, newToast]);
56 | setTimeout(() => removeToast(newToast), 5000); // Remove toast after 5 seconds
57 | };
58 |
59 | const removeToast = (toastToRemove: { type: string; message: string }) => {
60 | setToasts((prevToasts) =>
61 | prevToasts.filter((toast) => toast !== toastToRemove)
62 | );
63 | };
64 |
65 | useEffect(() => {
66 | const root = document.querySelector(":root");
67 | if (root) {
68 | if (theme === "light") {
69 | root.classList.add("theme-light");
70 | root.classList.remove("theme-dark");
71 | } else if (theme === "dark") {
72 | root.classList.add("theme-dark");
73 | root.classList.remove("theme-light");
74 | }
75 | }
76 | }, [theme]);
77 |
78 | const _setIsAuthenticated = (_isAuthenticated: boolean) => {
79 | setIsAuthenticated(_isAuthenticated);
80 | };
81 |
82 | const _setUser = (_user: INeynarAuthenticatedUser | null) => {
83 | setUser(_user);
84 | };
85 |
86 | const logoutUser = () => {
87 | if (user) {
88 | const { signer_uuid, ...rest } = user;
89 | setUser(null);
90 | setIsAuthenticated(false);
91 | localStorage.removeItem(LocalStorageKeys.NEYNAR_AUTHENTICATED_USER);
92 | if (eventsCallbacks?.onSignout) {
93 | eventsCallbacks.onSignout(rest);
94 | }
95 | }
96 | };
97 |
98 | const value = useMemo(
99 | () => ({
100 | client_id,
101 | theme,
102 | isAuthenticated,
103 | user,
104 | setTheme,
105 | showToast,
106 | logoutUser,
107 | }),
108 | [client_id, theme, isAuthenticated, user, setTheme, showToast, logoutUser]
109 | );
110 |
111 | return (
112 |
113 |
121 | {children}
122 |
123 | {toasts.map((toast, index) => (
124 |
125 | {toast.message}
126 |
127 | ))}
128 |
129 |
130 |
131 | );
132 | };
133 |
134 | export const useNeynarContext = () => {
135 | const context = useContext(NeynarContext);
136 | if (!context) {
137 | throw new Error(
138 | "useNeynarContext must be used within a NeynarContextProvider"
139 | );
140 | }
141 | return context;
142 | };
143 |
--------------------------------------------------------------------------------
/src/contexts/index.tsx:
--------------------------------------------------------------------------------
1 | export {
2 | NeynarContextProvider,
3 | useNeynarContext,
4 | } from "./NeynarContextProvider";
5 |
--------------------------------------------------------------------------------
/src/enums.ts:
--------------------------------------------------------------------------------
1 | export enum Theme {
2 | Light = "light",
3 | Dark = "dark",
4 | }
5 |
6 | export enum SIWN_variant {
7 | FARCASTER = "farcaster",
8 | NEYNAR = "neynar",
9 | WARPCAST = "warpcast",
10 | }
11 |
--------------------------------------------------------------------------------
/src/hooks/index.ts:
--------------------------------------------------------------------------------
1 | export { useLocalStorage } from "./use-local-storage-state";
2 |
--------------------------------------------------------------------------------
/src/hooks/use-local-storage-state.ts:
--------------------------------------------------------------------------------
1 | import { useState } from "react";
2 |
3 | type SerializeFunction = (value: T) => string;
4 | type DeserializeFunction = (value: string) => T;
5 |
6 | interface UseLocalStorageStateOptions {
7 | serialize?: SerializeFunction;
8 | deserialize?: DeserializeFunction;
9 | }
10 |
11 | export function useLocalStorage(
12 | key: string,
13 | defaultValue: T = "" as T,
14 | {
15 | serialize = JSON.stringify,
16 | deserialize = JSON.parse,
17 | }: UseLocalStorageStateOptions = {}
18 | ): [T, (value: T) => void, () => void] {
19 | const [storedValue, setStoredValue] = useState(() => {
20 | if (typeof window === "undefined") {
21 | return defaultValue;
22 | }
23 | try {
24 | const item = window.localStorage.getItem(key);
25 | return item ? deserialize(item) : defaultValue;
26 | } catch (error) {
27 | console.error("Error reading from localStorage", error);
28 | return defaultValue;
29 | }
30 | });
31 |
32 | const setValue = (value: T) => {
33 | try {
34 | const valueToStore =
35 | value instanceof Function ? value(storedValue) : value;
36 | setStoredValue(valueToStore);
37 | if (typeof window !== "undefined") {
38 | window.localStorage.setItem(key, serialize(valueToStore));
39 | }
40 | } catch (error) {
41 | console.error("Error writing to localStorage", error);
42 | }
43 | };
44 |
45 | const removeItem = () => {
46 | try {
47 | window.localStorage.removeItem(key);
48 | setStoredValue(defaultValue);
49 | } catch (error) {
50 | console.error("Error removing from localStorage", error);
51 | }
52 | };
53 |
54 | return [storedValue, setValue, removeItem];
55 | }
56 |
57 | export enum LocalStorageKeys {
58 | NEYNAR_AUTHENTICATED_USER = "neynar_authenticated_user",
59 | }
60 |
--------------------------------------------------------------------------------
/src/index.tsx:
--------------------------------------------------------------------------------
1 | import "@pigment-css/react/styles.css";
2 |
3 | // Components
4 | export * from "./components";
5 |
6 | // Contexts
7 | export * from "./contexts";
8 |
9 | // Hooks
10 | export * from "./hooks";
11 |
12 | // Enums
13 | export { Theme, SIWN_variant } from "./enums";
14 |
--------------------------------------------------------------------------------
/src/theme/index.ts:
--------------------------------------------------------------------------------
1 | import { extendTheme } from "@pigment-css/vite-plugin";
2 |
3 | export const theme = extendTheme({
4 | colorSchemes: {
5 | light: {
6 | palette: {
7 | background: "#FFFFFF",
8 | border: "#E0E0E0",
9 | text: "#2B2432",
10 | textMuted: "#6A6A6C",
11 | },
12 | },
13 | dark: {
14 | palette: {
15 | background: "#15111D",
16 | border: "#2E3031",
17 | text: "#FFFFFF",
18 | textMuted: "#A0A3AD",
19 | },
20 | },
21 | },
22 | colors: {
23 | primary: "#8A63D2",
24 | },
25 | typography: {
26 | fonts: {
27 | base: "Sora, sans-serif",
28 | },
29 | fontWeights: {
30 | regular: 400,
31 | bold: 700,
32 | },
33 | fontSizes: {
34 | large: "20px",
35 | medium: "15px",
36 | small: "12px",
37 | },
38 | },
39 | getSelector: (colorScheme) =>
40 | colorScheme ? `.theme-${colorScheme}` : ":root",
41 | });
42 |
--------------------------------------------------------------------------------
/src/types/common.ts:
--------------------------------------------------------------------------------
1 | export type SetState = React.Dispatch>;
2 |
3 | export interface INeynarAuthenticatedUser {
4 | signer_uuid: string;
5 | object: "user";
6 | fid: number;
7 | username: string;
8 | display_name?: string;
9 | custody_address: string;
10 | pfp_url?: string;
11 | profile: {
12 | bio: {
13 | text: string;
14 | mentioned_profiles?: string[];
15 | };
16 | };
17 | follower_count: number;
18 | following_count: number;
19 | verifications?: string[];
20 | verified_addresses: {
21 | eth_addresses?: string[];
22 | sol_addresses?: string[];
23 | };
24 | active_status: "active" | "inactive";
25 | power_badge: boolean;
26 | viewer_context?: {
27 | following: boolean;
28 | followed_by: boolean;
29 | };
30 | }
31 |
32 | export interface IUser {
33 | object: "user";
34 | fid: number;
35 | username: string;
36 | display_name?: string;
37 | custody_address: string;
38 | pfp_url?: string;
39 | profile: {
40 | bio: {
41 | text: string;
42 | mentioned_profiles?: string[];
43 | };
44 | };
45 | follower_count: number;
46 | following_count: number;
47 | verifications?: string[];
48 | verified_addresses: {
49 | eth_addresses?: string[];
50 | sol_addresses?: string[];
51 | };
52 | active_status: "active" | "inactive";
53 | power_badge: boolean;
54 | viewer_context?: {
55 | following: boolean;
56 | followed_by: boolean;
57 | };
58 | }
59 |
--------------------------------------------------------------------------------
/src/types/global.d.ts:
--------------------------------------------------------------------------------
1 | import type {} from "@pigment-css/react/theme";
2 | import type { ExtendTheme } from "@pigment-css/react";
3 |
4 | declare module "@pigment-css/react/theme" {
5 | export interface ThemeArgs {
6 | theme: ExtendTheme<{
7 | colorScheme: "light" | "dark";
8 | tokens: {
9 | palette: {
10 | background: string;
11 | foreground: string;
12 | primary: string;
13 | primaryForeground: string;
14 | border: string;
15 | };
16 | };
17 | }>;
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/src/utils/formatUtils.ts:
--------------------------------------------------------------------------------
1 | export function formatToReadableNumber(num: number): string {
2 | // Cap the number to 1 billion
3 | const cappedNum = Math.min(num, 1_000_000_000);
4 | if (cappedNum >= 1_000_000_000) {
5 | return Math.floor(cappedNum / 100_000_000) / 10 + "B";
6 | }
7 | if (cappedNum >= 1_000_000) {
8 | return Math.floor(cappedNum / 100_000) / 10 + "M";
9 | }
10 | if (cappedNum >= 1_000) {
11 | return Math.floor(cappedNum / 100) / 10 + "K";
12 | }
13 | return cappedNum.toString();
14 | }
15 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ESNext",
4 | "useDefineForClassFields": true,
5 | "lib": ["DOM", "DOM.Iterable", "ESNext"],
6 | "allowJs": false,
7 | "skipLibCheck": true,
8 | "esModuleInterop": false,
9 | "allowSyntheticDefaultImports": true,
10 | "strict": true,
11 | "forceConsistentCasingInFileNames": true,
12 | "module": "ESNext",
13 | "moduleResolution": "Node",
14 | "resolveJsonModule": true,
15 | "isolatedModules": true,
16 | "noEmit": true,
17 | "jsx": "react-jsx"
18 | },
19 | "include": ["src/**/*"],
20 | "exclude": ["node_modules", "dist", "src/**/*.stories.tsx"]
21 | }
22 |
--------------------------------------------------------------------------------
/vite.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from "vite";
2 | import react from "@vitejs/plugin-react";
3 | import { pigment } from "@pigment-css/vite-plugin";
4 | import tsconfigPaths from "vite-tsconfig-paths";
5 | import dts from "vite-plugin-dts";
6 | import { theme } from "./src/theme/index";
7 | import { config } from "dotenv";
8 |
9 | config({ path: ".env.local" });
10 |
11 | // https://vitejs.dev/config/
12 | export default defineConfig({
13 | plugins: [
14 | pigment({
15 | theme,
16 | }),
17 | react(),
18 | tsconfigPaths(),
19 | dts({
20 | insertTypesEntry: true, // This option adds an entry for the type definitions in your package.json
21 | }),
22 | ],
23 | define: {
24 | "process.env": process.env,
25 | },
26 | build: {
27 | outDir: "dist",
28 | lib: {
29 | entry: "src/index.tsx",
30 | formats: ["es", "cjs"],
31 | fileName: (format) => `bundle.${format}.js`,
32 | },
33 | rollupOptions: {
34 | external: ["react", "react-dom"],
35 | output: {
36 | globals: {
37 | react: "React",
38 | "react-dom": "ReactDOM",
39 | },
40 | },
41 | },
42 | },
43 | });
44 |
--------------------------------------------------------------------------------