├── .env.example ├── .github └── workflows │ └── publish-to-npm.yaml ├── .gitignore ├── .npmignore ├── .storybook ├── main.ts └── preview.tsx ├── LICENSE ├── README.md ├── package.json ├── src ├── components │ ├── atoms │ │ ├── Avatar │ │ │ └── index.tsx │ │ ├── Box │ │ │ └── index.tsx │ │ ├── Button │ │ │ ├── ButtonOutlined │ │ │ │ └── index.tsx │ │ │ └── ButtonPrimary │ │ │ │ └── index.tsx │ │ ├── Reactions │ │ │ └── index.tsx │ │ ├── Toast │ │ │ ├── ToastContainer │ │ │ │ └── index.tsx │ │ │ └── ToastItem │ │ │ │ └── index.tsx │ │ └── icons │ │ │ ├── CommentIcon.tsx │ │ │ ├── ExternalLinkIcon.tsx │ │ │ ├── FarcasterIcon.tsx │ │ │ ├── LightningIcon.tsx │ │ │ ├── LikeIcon.tsx │ │ │ ├── PlanetBlackIcon.tsx │ │ │ ├── RecastIcon.tsx │ │ │ ├── ShareIcon.tsx │ │ │ ├── ShareToClipboardIcon.tsx │ │ │ ├── WarpcastIcon.tsx │ │ │ ├── WarpcastPowerBadge.tsx │ │ │ └── XIcon.tsx │ ├── index.tsx │ ├── molecules │ │ ├── CastCard.tsx │ │ ├── ConversationList.tsx │ │ ├── FeedList.tsx │ │ ├── FrameCard.tsx │ │ ├── ProfileCard.tsx │ │ └── UserDropdown.tsx │ ├── organisms │ │ ├── NeynarAuthButton │ │ │ └── index.tsx │ │ ├── NeynarCastCard │ │ │ ├── hooks │ │ │ │ ├── useLinkifyCast.tsx │ │ │ │ └── useRenderEmbeds.tsx │ │ │ └── index.tsx │ │ ├── NeynarConversationList │ │ │ └── index.tsx │ │ ├── NeynarFeedList │ │ │ └── index.tsx │ │ ├── NeynarFrameCard │ │ │ └── index.tsx │ │ ├── NeynarProfileCard │ │ │ ├── hooks │ │ │ │ └── useLinkifyBio.tsx │ │ │ └── index.tsx │ │ └── NeynarUserDropdown │ │ │ └── index.tsx │ └── stories │ │ ├── NeynarAuthButton.stories.tsx │ │ ├── NeynarCastCard.stories.tsx │ │ ├── NeynarConversationList.stories.tsx │ │ ├── NeynarFeedList.stories.tsx │ │ ├── NeynarFrameCard.stories.tsx │ │ ├── NeynarProfileCard.stories.tsx │ │ └── NeynarUserDropdown.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 │ ├── fetcher.ts │ └── 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 -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | /dist 3 | 4 | test-sdk.ts 5 | *storybook.log 6 | 7 | .env 8 | .env.local 9 | .env.development 10 | .env.production 11 | -------------------------------------------------------------------------------- /.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 | 6 | addons: [ 7 | "@storybook/addon-onboarding", 8 | "@storybook/addon-links", 9 | "@storybook/addon-essentials", 10 | "@chromatic-com/storybook", 11 | "@storybook/addon-interactions", 12 | '@storybook/addon-themes', 13 | "storybook-source-link" 14 | ], 15 | 16 | framework: { 17 | name: "@storybook/react-vite", 18 | options: {}, 19 | }, 20 | 21 | docs: {}, 22 | 23 | typescript: { 24 | reactDocgen: "react-docgen-typescript" 25 | } 26 | }; 27 | export default config; 28 | -------------------------------------------------------------------------------- /.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 | 43 | parameters: { 44 | controls: { 45 | matchers: { 46 | color: /(background|color)$/i, 47 | date: /Date$/i, 48 | }, 49 | }, 50 | sourceLink: 'https://github.com/neynarxyz/react/', 51 | }, 52 | 53 | tags: ["autodocs"] 54 | }; 55 | 56 | export default preview; 57 | -------------------------------------------------------------------------------- /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 | ## `npm` package 4 | Download npm package [here](https://www.npmjs.com/package/@neynar/react). 5 | 6 | -- -- -- -- -- -- 7 | 8 | ## Introduction 9 | 10 | `@neynar/react` is the official Frontend SDK from [Neynar](https://neynar.com/). This SDK includes React components to build Farcaster clients. 11 | 12 | You can also test the components out in our [Storybook](https://neynar-react.vercel.app). 13 | 14 | ## Installation 15 | 16 | 1. Install the `@neynar/react` package using npm or yarn. 17 | 18 | For **yarn**: 19 | ```bash 20 | yarn add @neynar/react 21 | ``` 22 | 23 | For **npm**: 24 | ```bash 25 | npm install @neynar/react 26 | ``` 27 | 28 | 2. Make sure that the following peer dependencies are installed. 29 | 30 | ```json 31 | { 32 | "hls.js": "^1.5.13", 33 | "@pigment-css/react": "^0.0.9", 34 | "react": "^18.3.0", 35 | "react-dom": "^18.3.0", 36 | "swr": "^2.2.5" 37 | } 38 | ``` 39 | 40 | or if you want to install them all at once: 41 | 42 | For **yarn**: 43 | ```bash 44 | yarn add react react-dom @pigment-css/react hls.js swr 45 | ``` 46 | 47 | For **npm**: 48 | ```bash 49 | npm install react react-dom @pigment-css/react hls.js swr 50 | ``` 51 | 52 | 3. Import the following CSS file in your project's root file (e.g., `layout.tsx` for a Next.js app). 53 | 54 | ```tsx 55 | import "@neynar/react/dist/style.css"; 56 | ``` 57 | 58 | ## Components 59 | _Note_: If you are using `` or if you're using `` with `allowReactions` enabled (using Neynar reactions), please set your authorized origin to your local (localhost:6006 for Storybook) and production environments at [dev.neynar.com](https://dev.neynar.com). 60 | 61 | ### `` 62 | This component lets you embed a Sign In With Neynar button in your app, which you can use for read-only or read + write access to the user's Farcaster account. 63 | 64 | Params: 65 | - `label?` (string): The text to display on the button. Default: "Sign in with Neynar" 66 | - `icon?` (ReactNode): The icon to display on the button. Default: Neynar logo 67 | - `variant?` (SIWN_variant): The variant of the button. Default: "primary" 68 | - `modalStyle?` (CSSProperties): The style of the modal. Default: {} 69 | - `modalButtonStyle?` (CSSProperties): The style of the modal button. Default: {} 70 | 71 | Usage: 72 | ```tsx 73 | import { NeynarAuthButton } from "@neynar/react"; 74 | 75 | 79 | ``` 80 | 81 | ### `` 82 | This component displays a user's Farcaster profile information. 83 | 84 | Params: 85 | - `fid` (number): The FID of the user to display. 86 | - `viewerFid?` (number): The FID of the viewer. Default: undefined. 87 | - `containerStyles?` (CSSProperties): Custom styles for the profile card. Default: {} 88 | 89 | Usage: 90 | ```tsx 91 | import { NeynarProfileCard } from "@neynar/react"; 92 | 93 | 97 | ``` 98 | ### `` 99 | This component is a dropdown to search for Farcaster users. 100 | 101 | Params: 102 | - `value` (string): The currently selected user value. 103 | - `onChange` (function): Callback function called with the new value when the user selection changes. 104 | - `style?` (CSSProperties): Custom styles for the dropdown. Default: undefined. 105 | - `placeholder?` (string): Placeholder text to display in the dropdown. Default: undefined. 106 | - `disabled?` (boolean): Boolean indicating whether the dropdown is disabled. Default: false. 107 | - `viewerFid?` (number): The FID of the viewer. Default: undefined. 108 | - `customStyles?` (object): Custom styles for various elements within the dropdown. Properties include: 109 | - `dropdown?` (CSSProperties): Styles for the dropdown container. 110 | - `listItem?` (CSSProperties): Styles for the individual list items. 111 | - `avatar?` (CSSProperties): Styles for the user's avatar. 112 | - `userInfo?` (CSSProperties): Styles for the user's information text. 113 | - `limit?` (number | null): The number of users that can be selected, or null for no limit. Default: null. 114 | 115 | Usage: 116 | ```tsx 117 | import { NeynarUserDropdown } from "@neynar/react"; 118 | 119 | console.log(newValue)} 122 | viewerFid={1} 123 | limit={5} 124 | /> 125 | ``` 126 | 127 | 128 | ### `` 129 | This component displays a specific cast (post) on Farcaster. 130 | 131 | Params: 132 | - `type` ('url' | 'hash'): The type of identifier used for the cast. 133 | - `identifier` (string): The identifier (either URL or hash) for the cast. 134 | - `viewerFid?` (number): The FID of the viewer. Default: undefined. 135 | - `allowReactions?` (boolean, default = false): Whether to allow reactions on the cast 136 | - `renderEmbeds`(boolean, default = true): Whether to allow rendering of cast embeds 137 | - `renderFrames`(boolean, default = false): Whether to allow rendering of cast frames(note: if you pass in true, you must also set a value for `onFrameBtnPress`) 138 | - `onLikeBtnPress`(() => boolean) A handler to add functionality when the like button is pressed. A response of `true` indicates the like action is successful 139 | - `onRecastBtnPress`(() => boolean) A handler to add functionality when the recast button is pressed. A response of `true` indicates the recast action is successful 140 | - `onCommentBtnPress`(() => boolean) A handler to add functionality when the comment button is pressed. A response of `true` indicates the comment action is successful 141 | - `onFrameBtnPress?: ( 142 | btnIndex: number, 143 | localFrame: NeynarFrame, 144 | setLocalFrame: React.Dispatch>, 145 | inputValue?: string 146 | ) => Promise;`: A handler to add functionality when a frame button is pressed. 147 | - `containerStyles?` (CSSProperties): Custom styles for the cast card's container. Default: {} 148 | - `textStyles?` (CSSProperties): Custom styles for the cast card's text. Default: {} 149 | 150 | Usage: 151 | ```tsx 152 | import { NeynarCastCard } from "@neynar/react"; 153 | 154 | 159 | ``` 160 | 161 | ### `` 162 | This component displays a list of casts (posts) on Farcaster. 163 | 164 | Params: 165 | - `feedType` ('following' | 'filter'): The type of feed to display. 166 | - `filterType?` ('fids' | 'parent_url' | 'channel_id' | 'embed_url' | 'global_trending'): The filter type to apply to the feed. Default: undefined. 167 | - `fid?` (number): The FID to filter the feed by. Default: undefined. 168 | - `fids?` (string): The FIDs to filter the feed by. Default: undefined. 169 | - `parentUrl?` (string): The parent URL to filter the feed by. Default: undefined. 170 | - `channelId?` (string): The channel ID to filter the feed by. Default: undefined. 171 | - `embedUrl?` (string): The embed URL to filter the feed by. Default: undefined. 172 | - `withRecasts?` (boolean): Whether to include recasts in the feed. Default: true. 173 | - `limit?` (number): The number of casts to display. Default: undefined. 174 | - `viewerFid?` (number): The FID of the viewer. Default: undefined. 175 | - `clientId?` (string): The client ID for the Neynar API. Default: undefined. 176 | 177 | Usage: 178 | ```tsx 179 | import { NeynarFeedList } from "@neynar/react"; 180 | 181 | 187 | ``` 188 | 189 | ### `` 190 | This component displays a conversation (thread) of casts (posts) on Farcaster. 191 | 192 | Params: 193 | - `type` ('url' | 'hash'): The type of identifier used for the conversation. 194 | - `identifier` (string): The identifier (either URL or hash) for the conversation. 195 | - `replyDepth?` (number): The depth of replies to include in the conversation. Default: 2. 196 | - `includeChronologicalParentCasts?` (boolean): Whether to include chronological parent casts in the conversation. Default: false. 197 | - `limit?` (number): The number of casts to display. Default: 20. 198 | - `viewerFid?` (number): The FID of the viewer. Default: undefined. 199 | 200 | Usage: 201 | ```tsx 202 | import { NeynarConversationList } from "@neynar/react"; 203 | 204 | 211 | ``` 212 | 213 | ### `` 214 | This component displays a specific frame on Farcaster. 215 | 216 | Params: 217 | - `url` (string): The URL to fetch the frame data from. 218 | - `onFrameBtnPress: ( 219 | btnIndex: number, 220 | localFrame: NeynarFrame, 221 | setLocalFrame: React.Dispatch>, 222 | inputValue?: string 223 | ) => Promise;`: A handler to add functionality when a frame button is pressed. 224 | - `initialFrame?` (NeynarFrame): The initial frame data to display. Default: undefined. 225 | 226 | Usage: 227 | ```tsx 228 | import { NeynarFrameCard } from "@neynar/react"; 229 | 230 | 233 | ``` 234 | 235 | ## How to securely implement write actions 236 | There are currently two components that offer props for developers to handle write actions: `NeynarCastCard`(write action handlers for cast reactions) and `NeynarFeedCard`(write action handlers for frame interactions). We highly recommend that you call Neynar's POST APIs(or other intended APIs) from your own, authenticated server to ensure that your Neynar API key credentials are not exposed on the client-side. Check out the [example app](https://github.com/neynarxyz/farcaster-examples/tree/main/wownar-react-sdk) below for a guide and example of securely implementing write actions. 237 | 238 | 239 | ## Example app 240 | 241 | 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`. 242 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@neynar/react", 3 | "version": "0.9.6", 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 | "hls.js": "^1.5.13", 24 | "react": "^18.3.0", 25 | "react-dom": "^18.3.0", 26 | "swr": "^2.2.5" 27 | }, 28 | "devDependencies": { 29 | "@babel/core": "^7.24.4", 30 | "@babel/preset-env": "^7.24.4", 31 | "@babel/preset-react": "^7.24.1", 32 | "@chromatic-com/storybook": "^1.3.3", 33 | "@pigment-css/react": "^0.0.9", 34 | "@pigment-css/vite-plugin": "^0.0.9", 35 | "@storybook/addon-essentials": "^8.2.9", 36 | "@storybook/addon-interactions": "^8.2.9", 37 | "@storybook/addon-links": "^8.2.9", 38 | "@storybook/addon-onboarding": "^8.2.9", 39 | "@storybook/addon-themes": "^8.2.9", 40 | "@storybook/blocks": "^8.2.9", 41 | "@storybook/react": "^8.2.9", 42 | "@storybook/react-vite": "^8.2.9", 43 | "@storybook/test": "^8.2.9", 44 | "@types/react": "^18.3.0", 45 | "@types/react-dom": "^18.3.0", 46 | "@vitejs/plugin-react": "^4.2.1", 47 | "axios": "^1.6.8", 48 | "dotenv": "^16.4.5", 49 | "eslint-plugin-storybook": "^0.8.0", 50 | "hls.js": "^1.5.13", 51 | "storybook": "^8.2.9", 52 | "swr": "^2.2.5", 53 | "typescript": "^5.4.5", 54 | "vite": "^5.2.10", 55 | "vite-plugin-dts": "^3.9.0", 56 | "vite-tsconfig-paths": "^4.3.2" 57 | }, 58 | "dependencies": { 59 | "storybook-source-link": "^4.0.1" 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/components/atoms/Avatar/index.tsx: -------------------------------------------------------------------------------- 1 | import { styled } from "@pigment-css/react"; 2 | import { HTMLAttributes } from "react"; 3 | 4 | interface IAvatarProps extends HTMLAttributes { 5 | width?: string; 6 | height?: string; 7 | } 8 | 9 | const Avatar = styled.img(() => ({ 10 | width: (props) => props.width || "45px", 11 | height: (props) => props.width || "45px", 12 | borderRadius: "50%", 13 | aspectRatio: 1 / 1, 14 | objectFit: "cover", 15 | })); 16 | 17 | export default Avatar; 18 | -------------------------------------------------------------------------------- /src/components/atoms/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 | 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 | 48 | export default Box; 49 | -------------------------------------------------------------------------------- /src/components/atoms/Button/ButtonOutlined/index.tsx: -------------------------------------------------------------------------------- 1 | import { styled } from "@pigment-css/react"; 2 | 3 | const ButtonOutlined = 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 ButtonOutlined; 20 | -------------------------------------------------------------------------------- /src/components/atoms/Button/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/atoms/Reactions/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from "react"; 2 | import { styled } from "@pigment-css/react"; 3 | import Box from "../Box"; 4 | import { NeynarAuthButton } from "../../organisms/NeynarAuthButton"; 5 | import { LocalStorageKeys } from "../../../hooks/use-local-storage-state"; 6 | import { NEYNAR_API_URL } from "../../../constants"; 7 | import { useNeynarContext } from "../../../contexts"; 8 | import { SIWN_variant } from "../../../enums"; 9 | import { CommentIcon } from "../icons/CommentIcon"; 10 | import { RecastIcon } from "../icons/RecastIcon"; 11 | import { LikeIcon } from "../icons/LikeIcon"; 12 | import XIcon from "../icons/XIcon"; 13 | import customFetch from "../../../utils/fetcher"; 14 | 15 | const ReactionWrapper = styled(Box)(({ theme }) => ({ 16 | display: "flex", 17 | justifyContent: "space-between", 18 | alignItems: "center", 19 | position: "relative", 20 | })) as typeof Box; 21 | 22 | const Popover = styled(Box)(({ theme }) => ({ 23 | position: "absolute", 24 | display: 'flex', 25 | flexDirection: 'row', 26 | alignItems: 'center', 27 | justifyContent: 'space-between', 28 | backgroundColor: theme?.colors?.background || "#fff", 29 | padding: '5px 8px', 30 | boxShadow: theme?.shadows?.[2] || "0px 4px 6px rgba(0, 0, 0, 0.1)", 31 | zIndex: theme?.zIndex?.popover || 2000, 32 | borderRadius: 4, 33 | minWidth: '245px', 34 | width: 'auto', 35 | maxWidth: '90vw', 36 | })) as typeof Box; 37 | 38 | const PopoverContent = styled(Box)({ 39 | display: 'flex', 40 | alignItems: 'center', 41 | justifyContent: 'center', 42 | flex: 1, 43 | }) as typeof Box; 44 | 45 | const CloseButtonWrapper = styled(Box)({ 46 | display: 'flex', 47 | alignItems: 'center', 48 | marginLeft: '8px', 49 | }) as typeof Box; 50 | 51 | type ReactionsProps = { 52 | hash: string; 53 | reactions: { 54 | likes_count: number; 55 | recasts_count: number; 56 | likes: { 57 | fid: number; 58 | fname: string; 59 | }[]; 60 | recasts: { 61 | fid: number; 62 | fname: string; 63 | }[]; 64 | }; 65 | onComment?: () => void; 66 | onRecast?: () => boolean; 67 | onLike?: () => boolean; 68 | isLiked: boolean; 69 | }; 70 | 71 | const Reactions: React.FC = ({ 72 | hash, 73 | reactions, 74 | onComment, 75 | onRecast, 76 | onLike, 77 | isLiked: isLikedProp, 78 | }) => { 79 | const { client_id, user, isAuthenticated } = useNeynarContext(); 80 | const [showPopover, setShowPopover] = React.useState(false); 81 | const [popoverPosition, setPopoverPosition] = React.useState({ top: 0, left: 0 }); 82 | const [signerValue, setSignerValue] = React.useState(null); 83 | const [isLiked, setIsLiked] = React.useState(isLikedProp); 84 | const [isRecasted, setIsRecasted] = React.useState(false); 85 | const popoverRef = React.useRef(null); 86 | const iconRefs = React.useRef<{ [key: string]: HTMLDivElement | null }>({ 87 | comment: null, 88 | recast: null, 89 | like: null, 90 | }); 91 | 92 | useEffect(() => { 93 | setIsLiked(reactions.likes.some(like => like.fid === user?.fid)); 94 | setIsRecasted(reactions.recasts.some(recast => recast.fid === user?.fid)); 95 | }, [reactions, user]); 96 | 97 | useEffect(() => { 98 | const signer = localStorage.getItem(LocalStorageKeys.NEYNAR_AUTHENTICATED_USER); 99 | if (signer) { 100 | try { 101 | setSignerValue(JSON.parse(signer).signer_uuid); 102 | } catch (e) { 103 | console.error("Error parsing JSON from local storage:", e); 104 | setSignerValue(null); 105 | } 106 | } else { 107 | console.warn("No NEYNAR_AUTHENTICATED_USER found in local storage."); 108 | } 109 | }, [isAuthenticated]); 110 | 111 | useEffect(() => { 112 | if ((signerValue || isAuthenticated) && showPopover) { 113 | setShowPopover(false); 114 | } 115 | }, [signerValue, isAuthenticated, showPopover]); 116 | const handleAction = async ( 117 | event: React.MouseEvent, 118 | actionName: string 119 | ) => { 120 | if (signerValue) { 121 | switch (actionName) { 122 | case "comment": 123 | if(onComment){ 124 | onComment() 125 | } else{ 126 | throw new Error("No comment handler function provided") 127 | } 128 | break; 129 | case "recast": 130 | if(onRecast){ 131 | setIsRecasted(onRecast()); 132 | } else{ 133 | throw new Error("No recast handler function provided") 134 | } 135 | break; 136 | case "like": 137 | if(onLike){ 138 | setIsLiked(onLike()); 139 | } else{ 140 | throw new Error("No like handler function provided") 141 | } 142 | break; 143 | default: 144 | break; 145 | } 146 | } 147 | const iconElement = iconRefs.current[actionName]; 148 | if (iconElement) { 149 | const iconRect = iconElement.getBoundingClientRect(); 150 | const popoverElement = popoverRef.current; 151 | if (popoverElement) { 152 | const popoverRect = popoverElement.getBoundingClientRect(); 153 | setPopoverPosition({ 154 | top: iconRect.top - popoverRect.height - 10, 155 | left: iconRect.left + (iconRect.width / 2) - (popoverRect.width / 2), 156 | }); 157 | } 158 | } 159 | }; 160 | 161 | return ( 162 | 163 | {showPopover && ( 164 | 165 | 166 | 167 | 168 | 169 | setShowPopover(false)} size={16} /> 170 | 171 | 172 | )} 173 | 180 | 181 |
(iconRefs.current.comment = el)}> 182 | handleAction(e, "comment")} /> 183 |
184 |
(iconRefs.current.recast = el)}> 185 | handleAction(e, "recast")} /> 186 |
187 |
(iconRefs.current.like = el)}> 188 | handleAction(e, "like")} /> 189 |
190 |
191 |
192 |
193 | ); 194 | }; 195 | 196 | export default Reactions; -------------------------------------------------------------------------------- /src/components/atoms/Toast/ToastContainer/index.tsx: -------------------------------------------------------------------------------- 1 | import { styled } from "@pigment-css/react"; 2 | 3 | export const ToastContainer = styled.div` 4 | position: fixed; 5 | bottom: 20px; 6 | right: 20px; 7 | z-index: 9999; 8 | `; 9 | -------------------------------------------------------------------------------- /src/components/atoms/Toast/ToastItem/index.tsx: -------------------------------------------------------------------------------- 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 | export const ToastItem = styled("div")<{ type: string }>((props) => ({ 19 | padding: "10px 20px", 20 | marginBottom: "10px", 21 | borderRadius: "5px", 22 | color: "#fff", 23 | animation: `${fadeInOut} 4s ease-out`, 24 | fontFamily: props.theme.typography.fonts.base, 25 | fontSize: props.theme.typography.fontSizes.medium, 26 | variants: [ 27 | { 28 | props: { type: "success" }, 29 | style: { 30 | backgroundColor: "#32cd32", 31 | }, 32 | }, 33 | { 34 | props: { type: "error" }, 35 | style: { 36 | backgroundColor: "#ff6347", 37 | }, 38 | }, 39 | { 40 | props: { type: "warning" }, 41 | style: { 42 | backgroundColor: "#ffa500", 43 | }, 44 | }, 45 | { 46 | props: { type: "info" }, 47 | style: { 48 | backgroundColor: "#3498db", 49 | }, 50 | }, 51 | ], 52 | })); 53 | 54 | export enum ToastType { 55 | Success = "success", 56 | Error = "error", 57 | Warning = "warning", 58 | Info = "info", 59 | } 60 | -------------------------------------------------------------------------------- /src/components/atoms/icons/CommentIcon.tsx: -------------------------------------------------------------------------------- 1 | export const CommentIcon = ({ onClick }: { onClick?: (e: React.MouseEvent) => void }) => { 2 | return( 3 | onClick ? onClick(e) : undefined} 10 | style={{ cursor: "pointer" }} 11 | > 12 | 16 | 17 | ) 18 | } -------------------------------------------------------------------------------- /src/components/atoms/icons/ExternalLinkIcon.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const ExternalLinkIcon = () => { 4 | return ( 5 | 6 | 7 | 8 | 9 | ); 10 | }; 11 | 12 | export default ExternalLinkIcon; -------------------------------------------------------------------------------- /src/components/atoms/icons/FarcasterIcon.tsx: -------------------------------------------------------------------------------- 1 | export const FarcasterIcon = () => { 2 | return ( 3 | 10 | 14 | 18 | 22 | 26 | 27 | ); 28 | }; 29 | -------------------------------------------------------------------------------- /src/components/atoms/icons/LightningIcon.tsx: -------------------------------------------------------------------------------- 1 | export const LightningIcon = () => { 2 | return( 3 | 4 | 5 | 6 | ) 7 | } -------------------------------------------------------------------------------- /src/components/atoms/icons/LikeIcon.tsx: -------------------------------------------------------------------------------- 1 | export const LikeIcon = ({ fill, onClick }: { fill?: string, onClick?: (e: React.MouseEvent) => void }) => { 2 | return( 3 | onClick ? onClick(e) : undefined} 10 | style={{ cursor: "pointer" }} 11 | > 12 | 17 | 18 | 19 | 20 | 26 | 27 | ) 28 | } -------------------------------------------------------------------------------- /src/components/atoms/icons/PlanetBlackIcon.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | const PlanetBlackIcon = () => ( 4 | 11 | 17 | 23 | 29 | 37 | 38 | ); 39 | 40 | export default PlanetBlackIcon; 41 | -------------------------------------------------------------------------------- /src/components/atoms/icons/RecastIcon.tsx: -------------------------------------------------------------------------------- 1 | export const RecastIcon = ({ fill, onClick }: { fill?: string, onClick?: (e: React.MouseEvent) => void }) => { 2 | return( 3 | onClick ? onClick(e) : undefined} 10 | style={{ cursor: "pointer" }} 11 | > 12 | 16 | 20 | 21 | ) 22 | } -------------------------------------------------------------------------------- /src/components/atoms/icons/ShareIcon.tsx: -------------------------------------------------------------------------------- 1 | export default function ShareIcon({ onClick }: { onClick?: (e: React.MouseEvent) => void }) { 2 | return( 3 | onClick ? onClick(e) : undefined} 11 | > 12 | 16 | 17 | ) 18 | } -------------------------------------------------------------------------------- /src/components/atoms/icons/ShareToClipboardIcon.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ShareIcon from './ShareIcon'; 3 | 4 | export const ShareToClipboardIcon = ({ url }: { url: string }) => { 5 | const [copied, setCopied] = React.useState(false); 6 | 7 | const handleShareClick = async (e: React.MouseEvent) => { 8 | try { 9 | await navigator.clipboard.writeText(url); 10 | setCopied(true); 11 | setTimeout(() => { 12 | setCopied(false); 13 | }, 2000); 14 | } catch (err) { 15 | console.error('Failed to copy the text to clipboard:', err); 16 | } 17 | }; 18 | 19 | return ( 20 |
21 | {copied ? ( 22 | 29 | 30 | 31 | ) : ( 32 | 33 | )} 34 |
35 | ); 36 | }; -------------------------------------------------------------------------------- /src/components/atoms/icons/WarpcastIcon.tsx: -------------------------------------------------------------------------------- 1 | export const WarpcastIcon = () => { 2 | return ( 3 | 10 | 18 | 19 | 20 | 21 | 25 | 29 | 30 | 31 | ); 32 | }; 33 | -------------------------------------------------------------------------------- /src/components/atoms/icons/WarpcastPowerBadge.tsx: -------------------------------------------------------------------------------- 1 | export const WarpcastPowerBadge = () => { 2 | return ( 3 | 10 | 11 | 15 | 16 | ); 17 | }; 18 | -------------------------------------------------------------------------------- /src/components/atoms/icons/XIcon.tsx: -------------------------------------------------------------------------------- 1 | export default function XIcon({ onClick, size = 24 } : { onClick?: () => void, size?: number }){ 2 | return ( 3 | 23 | ); 24 | }; -------------------------------------------------------------------------------- /src/components/index.tsx: -------------------------------------------------------------------------------- 1 | export { NeynarAuthButton } from "./organisms/NeynarAuthButton"; 2 | export { CastCard } from "./molecules/CastCard"; 3 | export { NeynarCastCard } from "./organisms/NeynarCastCard"; 4 | export { NeynarConversationList } from "./organisms/NeynarConversationList"; 5 | export { NeynarFeedList } from "./organisms/NeynarFeedList"; 6 | export { NeynarFrameCard } from "./organisms/NeynarFrameCard"; 7 | export { NeynarProfileCard } from "./organisms/NeynarProfileCard"; 8 | export { NeynarUserDropdown } from "./organisms/NeynarUserDropdown"; -------------------------------------------------------------------------------- /src/components/molecules/CastCard.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect, useMemo, useCallback } from "react"; 2 | import { styled } from "@pigment-css/react"; 3 | import Avatar from "../atoms/Avatar"; 4 | import { useLinkifyCast } from "../organisms/NeynarCastCard/hooks/useLinkifyCast"; 5 | import Box, { HBox, VBox } from "../atoms/Box"; 6 | import { WarpcastPowerBadge } from "../atoms/icons/WarpcastPowerBadge"; 7 | import { useRenderEmbeds } from "../organisms/NeynarCastCard/hooks/useRenderEmbeds"; 8 | import Reactions from "../atoms/Reactions"; 9 | import { ShareToClipboardIcon } from "../atoms/icons/ShareToClipboardIcon"; 10 | import { SKELETON_PFP_URL } from "../../constants"; 11 | import { NeynarFrameCard, type NeynarFrame } from "../organisms/NeynarFrameCard"; 12 | 13 | const StyledCastCard = styled.div(({ theme }) => ({ 14 | display: "flex", 15 | flexDirection: "column", 16 | width: "100%", 17 | maxWidth: "608px", 18 | borderStyle: "solid", 19 | borderColor: theme.vars.palette.border, 20 | borderRadius: "15px", 21 | padding: "30px", 22 | color: theme.vars.palette.text, 23 | fontFamily: theme.typography.fonts.base, 24 | fontSize: theme.typography.fontSizes.medium, 25 | backgroundColor: theme.vars.palette.background, 26 | position: "relative", 27 | "@media (max-width: 600px)": { 28 | padding: "15px", 29 | fontSize: theme.typography.fontSizes.small, 30 | borderRadius: "0px", 31 | } 32 | })); 33 | 34 | const StyledLink = styled.a(({ theme }) => ({ 35 | textDecoration: "none", 36 | color: theme.vars.palette.textMuted, 37 | })); 38 | 39 | const Main = styled.div(() => ({ 40 | display: "flex", 41 | flexDirection: "column", 42 | justifyContent: "space-between", 43 | flex: 1, 44 | })); 45 | 46 | const Username = styled.div(({ theme }) => ({ 47 | color: theme.vars.palette.textMuted, 48 | })); 49 | 50 | const UsernameTitle = styled.div(({ theme }) => ({ 51 | fontSize: theme.typography.fontSizes.large, 52 | fontWeight: theme.typography.fontWeights.bold, 53 | "@media (max-width: 600px)": { 54 | fontSize: theme.typography.fontSizes.medium, 55 | } 56 | })); 57 | 58 | const ProfileMetaCell = styled.div(({ theme }) => ({ 59 | color: theme.vars.palette.textMuted, 60 | "> strong": { 61 | color: theme.vars.palette.text, 62 | }, 63 | "& + &": { 64 | marginLeft: "15px", 65 | }, 66 | })); 67 | 68 | const Tag = styled.div(({ theme }) => ({ 69 | borderWidth: "1px", 70 | borderStyle: "solid", 71 | borderColor: theme.vars.palette.border, 72 | borderRadius: "5px", 73 | padding: "3px 6px", 74 | marginTop: "3px", 75 | marginLeft: "5px", 76 | backgroundColor: "transparent", 77 | fontSize: theme.typography.fontSizes.small, 78 | color: theme.vars.palette.textMuted, 79 | lineHeight: 1, 80 | })); 81 | 82 | const LinkifiedText = styled.div(() => ({ 83 | whiteSpace: 'pre-line', 84 | })); 85 | 86 | const EmbedsContainer = styled.div(() => ({ 87 | display: 'flex', 88 | flexDirection: 'column', 89 | gap: '1px', 90 | alignItems: 'center', 91 | padding: 0, 92 | border: 'none', 93 | borderRadius: '8px', 94 | width: '100%', 95 | marginBottom: '15px' 96 | })); 97 | 98 | const RepliesLikesContainer = styled.div(() => ({ 99 | display: 'flex', 100 | gap: '4px', 101 | alignItems: 'center', 102 | })); 103 | 104 | const ReactionsContainer = styled.div(() => ({ 105 | flexDirection: 'row', 106 | display: 'flex', 107 | alignItems: 'center', 108 | paddingRight: 4 109 | })); 110 | 111 | const SpaceBetweenContainer = styled.div(() => ({ 112 | flexDirection: 'row', 113 | display: 'flex', 114 | alignItems: 'center', 115 | paddingRight: 4 116 | })); 117 | 118 | export type CastCardProps = { 119 | username: string; 120 | displayName: string; 121 | avatarImgUrl: string; 122 | text: string; 123 | hash: string; 124 | reactions: { 125 | likes_count: number; 126 | recasts_count: number; 127 | likes: { 128 | fid: number; 129 | fname: string; 130 | }[]; 131 | recasts: { 132 | fid: number; 133 | fname: string; 134 | }[]; 135 | }; 136 | replies: number; 137 | embeds: any[]; 138 | frames: NeynarFrame[]; 139 | channel?: { 140 | id: string; 141 | name: string; 142 | url: string; 143 | }; 144 | viewerFid?: number; 145 | hasPowerBadge: boolean; 146 | isOwnProfile?: boolean; 147 | isEmbed?: boolean; 148 | allowReactions: boolean; 149 | renderEmbeds: boolean; 150 | renderFrames: boolean; 151 | onLikeBtnPress?: () => boolean; 152 | onRecastBtnPress?: () => boolean; 153 | onCommentBtnPress?: () => void; 154 | onFrameBtnPress?: ( 155 | btnIndex: number, 156 | localFrame: NeynarFrame, 157 | setLocalFrame: React.Dispatch>, 158 | inputValue?: string 159 | ) => Promise; 160 | direct_replies?: CastCardProps[]; 161 | containerStyles?: React.CSSProperties; 162 | textStyles?: React.CSSProperties; 163 | }; 164 | 165 | export const CastCard = React.memo( 166 | ({ 167 | username, 168 | displayName, 169 | avatarImgUrl, 170 | text = '', 171 | hash, 172 | reactions, 173 | replies, 174 | embeds = [], 175 | frames = [], 176 | channel, 177 | viewerFid, 178 | hasPowerBadge, 179 | isEmbed = true, 180 | allowReactions, 181 | renderEmbeds, 182 | renderFrames, 183 | onLikeBtnPress, 184 | onRecastBtnPress, 185 | onCommentBtnPress, 186 | onFrameBtnPress, 187 | direct_replies, 188 | containerStyles, 189 | textStyles 190 | }: CastCardProps) => { 191 | const [likesCount, setLikesCount] = useState(reactions.likes_count); 192 | const [isLiked, setIsLiked] = useState(reactions.likes.some(like => like.fid === viewerFid)); 193 | const linkifiedText = useLinkifyCast(text, embeds); 194 | const isSingle = embeds?.length === 1; 195 | 196 | const framesUrls = useMemo(() => frames.map(frame => frame.frames_url), [frames]); 197 | const filteredEmbeds = useMemo(() => embeds.filter(embed => !framesUrls.includes(embed.url)), [embeds, framesUrls]); 198 | 199 | const handleError = useCallback((e: React.SyntheticEvent) => { 200 | e.currentTarget.src = SKELETON_PFP_URL; 201 | }, []); 202 | 203 | useEffect(() => { 204 | setIsLiked(reactions.likes.some(like => like.fid === viewerFid)); 205 | }, [reactions.likes, viewerFid]); 206 | 207 | const handleLike = useCallback(() => { 208 | if (onLikeBtnPress) { 209 | const likeBtnPressResp = onLikeBtnPress(); 210 | if(likeBtnPressResp){ 211 | setLikesCount(prev => prev + 1); 212 | setIsLiked(!isLiked); 213 | return true; 214 | } 215 | } 216 | return false; 217 | }, [onLikeBtnPress]); 218 | 219 | const renderedEmbeds = useRenderEmbeds(filteredEmbeds, allowReactions, viewerFid); 220 | 221 | return ( 222 | 223 | 224 | 225 | 0 ? avatarImgUrl : SKELETON_PFP_URL} 227 | onError={handleError} 228 | loading="lazy" 229 | alt={`${displayName ?? 'Skeleton'} Avatar`} 230 | /> 231 | 232 |
233 | 234 | 235 | 236 | {displayName} 237 | {hasPowerBadge && ( 238 | 239 | 240 | 241 | )} 242 | 243 | 244 | @{username} 245 | 246 | 247 | 248 | 249 | 250 | {linkifiedText} 251 | 252 | {renderEmbeds && filteredEmbeds && filteredEmbeds.length > 0 ? ( 253 | 254 | {renderedEmbeds.map((embed, index) => ( 255 |
256 | {embed} 257 |
258 | ))} 259 |
260 | ) : <>} 261 | { 262 | renderFrames && frames && frames.length > 0 ? ( 263 | 264 | {frames.map((frame: NeynarFrame) => ( 265 | 271 | ))} 272 | 273 | ) : null 274 | } 275 | 276 | {allowReactions && ( 277 | 285 | )} 286 | {allowReactions && username && hash && } 287 | 288 | 289 | 290 |
{replies} replies
291 |
·
292 |
{likesCount} likes
293 | {channel && 294 | <> 295 |
·
296 | 297 | /{channel.id} 298 | 299 | 300 | } 301 |
302 | {!allowReactions && username && hash && } 303 |
304 |
305 |
306 |
307 | ); 308 | } 309 | ); 310 | -------------------------------------------------------------------------------- /src/components/molecules/ConversationList.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Box from '../atoms/Box'; 3 | import { CastCard, CastCardProps } from './CastCard'; 4 | import { styled } from '@pigment-css/react'; 5 | 6 | const StyledConversationList = styled.div(({ theme }) => ({ 7 | borderWidth: "1px", 8 | borderStyle: "solid", 9 | borderColor: theme.vars.palette.border, 10 | borderRadius: "15px", 11 | color: theme.vars.palette.text, 12 | fontFamily: theme.typography.fonts.base, 13 | fontSize: theme.typography.fontSizes.medium, 14 | backgroundColor: theme.vars.palette.background, 15 | width: 'auto', 16 | maxWidth: "750px", 17 | padding: "20px", 18 | })); 19 | 20 | const ReplyWrapper = styled.div(() => ({ 21 | display: "flex", 22 | alignItems: "flex-start", 23 | position: "relative", 24 | })); 25 | 26 | const VerticalLine = styled.div(({ theme }) => ({ 27 | width: "2px", 28 | backgroundColor: theme.vars.palette.border, 29 | position: "absolute", 30 | top: "40px", 31 | bottom: "0", 32 | left: "27px", 33 | zIndex: "1", 34 | })); 35 | 36 | const HorizontalLine = styled.div(({ theme }) => ({ 37 | width: "100%", 38 | height: "1px", 39 | backgroundColor: theme.vars.palette.border, 40 | margin: "20px 0", 41 | })); 42 | 43 | const ReplyContent = styled.div(() => ({ 44 | marginLeft: "0px", 45 | zIndex: "2", 46 | width: "100%", 47 | })); 48 | 49 | export default function ConversationList(props: { casts: CastCardProps[] }) { 50 | return ( 51 | 52 | {props.casts.map((cast, index) => ( 53 | 54 | {index !== 0 && } 55 | 56 | 57 | {index === 0 && } 58 | {cast.direct_replies && cast.direct_replies.length > 0 && ( 59 | cast.direct_replies.map((reply, replyIndex) => ( 60 | 61 | 62 | 63 | 64 | 65 | 66 | )) 67 | )} 68 | 69 | 70 | ))} 71 | 72 | ); 73 | } -------------------------------------------------------------------------------- /src/components/molecules/FeedList.tsx: -------------------------------------------------------------------------------- 1 | import React, { memo } from "react"; 2 | import { styled } from "@pigment-css/react"; 3 | import { CastCard, CastCardProps } from "./CastCard"; 4 | 5 | const StyledFeedList = styled.div(({ theme }) => ({ 6 | display: "flex", 7 | flexDirection: "column", 8 | borderWidth: "1px", 9 | borderStyle: "solid", 10 | borderColor: theme.vars.palette.border, 11 | borderRadius: "12px", 12 | color: theme.vars.palette.text, 13 | fontFamily: theme.typography.fonts.base, 14 | fontSize: theme.typography.fontSizes.medium, 15 | backgroundColor: theme.vars.palette.background, 16 | gap: "5px", 17 | paddingTop: "5px", 18 | paddingBottom: "5px", 19 | width: 'auto', 20 | maxWidth: "700px", 21 | })); 22 | 23 | const HorizontalLine = styled.div(({ theme }) => ({ 24 | width: "100%", 25 | height: "1px", 26 | backgroundColor: theme.vars.palette.border, 27 | })); 28 | 29 | export type FeedListProps = { 30 | casts: CastCardProps[]; 31 | cursor: string; 32 | }; 33 | 34 | export const FeedList = memo( 35 | ({ casts, cursor }: FeedListProps) => { 36 | return ( 37 | 38 | {casts.map((cast: CastCardProps, index: number) => ( 39 | 40 | 41 | {index < casts.length - 1 && } 42 | 43 | ))} 44 | 45 | ); 46 | } 47 | ); -------------------------------------------------------------------------------- /src/components/molecules/FrameCard.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useRef, useEffect } from "react"; 2 | import { styled } from "@pigment-css/react"; 3 | import ExternalLinkIcon from "../atoms/icons/ExternalLinkIcon"; 4 | import { NeynarFrame } from "../organisms/NeynarFrameCard"; 5 | import { LightningIcon } from "../atoms/icons/LightningIcon"; 6 | 7 | export type FrameCardProps = { 8 | frame: NeynarFrame | null; 9 | onFrameBtnPress: ( 10 | btnIndex: number, 11 | localFrame: NeynarFrame, 12 | setLocalFrame: React.Dispatch>, 13 | inputValue?: string 14 | ) => Promise; 15 | }; 16 | 17 | const FrameButton = styled.button({ 18 | border: "1px solid rgba(255, 255, 255, 0.2)", 19 | borderRadius: "8px", 20 | padding: "6px 16px", 21 | fontSize: "14px", 22 | display: "flex", 23 | alignItems: "center", 24 | justifyContent: "center", 25 | gap: "8px", 26 | cursor: 'pointer', 27 | backgroundColor: "#1E1E1E", 28 | color: "white", 29 | flex: "1 1 0", 30 | minWidth: "0", 31 | '&:hover': { 32 | backgroundColor: "#2E2E2E", 33 | } 34 | }); 35 | 36 | const FrameContainer = styled.div({ 37 | border: "1px solid rgba(255, 255, 255, 0.1)", 38 | margin: "8px 0", 39 | backgroundColor: "#121212", 40 | borderRadius: "12px", 41 | display: "flex", 42 | flexDirection: "column", 43 | alignItems: "right", 44 | width: "100%", 45 | maxWidth: "400px", 46 | position: "relative", 47 | }); 48 | 49 | const ButtonContainer = styled.div({ 50 | margin: "7px", 51 | display: "flex", 52 | flexWrap: "wrap", 53 | gap: "8px", 54 | width: "97%", 55 | }); 56 | 57 | const FrameImage = styled.img({ 58 | width: "100%", 59 | borderTopLeftRadius: "8px", 60 | borderTopRightRadius: "8px", 61 | }); 62 | 63 | const FrameDomain = styled.div({ 64 | fontSize: "12px", 65 | color: "#888", 66 | marginTop: "auto", 67 | width: "100%", 68 | padding: "4px 0" 69 | }); 70 | 71 | const FlexContainer = styled.div({ 72 | display: "flex", 73 | flexDirection: "column", 74 | gap: "4px", 75 | width: "100%", 76 | }); 77 | 78 | const InputField = styled.input({ 79 | border: "1px solid rgba(255, 255, 255, 0.2)", 80 | borderRadius: "8px", 81 | padding: "2%", 82 | display: "flex", 83 | flexWrap: "wrap", 84 | gap: "8px", 85 | width: "94%", 86 | marginLeft: "1%", 87 | fontSize: "14px", 88 | backgroundColor: "#1E1E1E", 89 | color: "white", 90 | }); 91 | 92 | const SpinnerOverlay = styled.div({ 93 | position: "absolute", 94 | top: 0, 95 | left: 0, 96 | width: "100%", 97 | height: "100%", 98 | display: "flex", 99 | alignItems: "center", 100 | justifyContent: "center", 101 | backgroundColor: "rgba(0, 0, 0, 0.5)", 102 | borderRadius: "12px", 103 | zIndex: 10, 104 | }); 105 | 106 | const FrameSpinnerSVG = () => { 107 | const spinnerRef = useRef(null); 108 | 109 | useEffect(() => { 110 | if (spinnerRef.current) { 111 | let rotation = 0; 112 | const animate = () => { 113 | rotation += 6; 114 | if (spinnerRef.current) { 115 | spinnerRef.current.style.transform = `rotate(${rotation}deg)`; 116 | } 117 | requestAnimationFrame(animate); 118 | }; 119 | requestAnimationFrame(animate); 120 | } 121 | }, []); 122 | 123 | return ( 124 | 134 | 135 | 136 | ); 137 | }; 138 | 139 | function CastFrameBtn({ number, text, actionType, target, frameUrl, handleOnClick }: any) { 140 | return ( 141 | handleOnClick(number)}> 142 | {text} 143 | {(actionType === "link" || actionType === "post_redirect" || actionType === "mint") && } 144 | {actionType === "tx" && } 145 | 146 | ) 147 | } 148 | 149 | function CastFrame({ frame, onFrameBtnPress }: { frame: NeynarFrame, onFrameBtnPress: FrameCardProps['onFrameBtnPress'] }) { 150 | const [localFrame, setLocalFrame] = useState(frame); 151 | const [inputValue, setInputValue] = useState(""); 152 | const [loading, setLoading] = useState(false); 153 | 154 | const renderFrameButtons = () => { 155 | const buttons = localFrame.buttons.map((btn) => ( 156 | { 164 | setLoading(true); 165 | onFrameBtnPress(btnIndex, localFrame, setLocalFrame, inputValue) 166 | .finally(() => setLoading(false)); 167 | }} 168 | /> 169 | )); 170 | return {buttons}; 171 | }; 172 | 173 | const handleSetInputValue = (newValue: string) => { 174 | setInputValue(newValue); 175 | } 176 | 177 | const extractDomain = (url: string) => { 178 | try { 179 | return new URL(url).hostname.replace('www.', ''); 180 | } catch (error) { 181 | return ''; 182 | } 183 | }; 184 | 185 | const getImageStyle = () => { 186 | switch (localFrame.image_aspect_ratio) { 187 | case "1:1": 188 | return { aspectRatio: "1 / 1" }; 189 | case "1.91:1": 190 | return { aspectRatio: "1.91 / 1" }; 191 | default: 192 | return { aspectRatio: "1.91 / 1" }; 193 | } 194 | }; 195 | 196 | return ( 197 | <> 198 | 199 | {loading && ( 200 | 201 | )} 202 | {localFrame.frames_url && ( 203 | <> 204 | 205 | 206 | 207 | {localFrame.input?.text && ( 208 | handleSetInputValue(e.target.value)} 213 | /> 214 | )} 215 | {renderFrameButtons()} 216 | 217 | )} 218 | 219 | {localFrame.frames_url && {extractDomain(localFrame.frames_url)}} 220 | 221 | ); 222 | } 223 | 224 | export const FrameCard: React.FC = ({ frame, onFrameBtnPress }) => { 225 | return ( 226 | 227 | {frame ? 228 | 229 | : <>} 230 | 231 | ); 232 | }; 233 | 234 | export default FrameCard; -------------------------------------------------------------------------------- /src/components/molecules/ProfileCard.tsx: -------------------------------------------------------------------------------- 1 | import { useMemo, memo } from "react"; 2 | import { styled } from "@pigment-css/react"; 3 | 4 | import Avatar from "../atoms/Avatar"; 5 | import ButtonPrimary from "../atoms/Button/ButtonPrimary"; 6 | import ButtonOutlined from "../atoms/Button/ButtonOutlined"; 7 | import { useLinkifyBio } from "../organisms/NeynarProfileCard/hooks/useLinkifyBio"; 8 | import Box, { HBox, VBox } from "../atoms/Box"; 9 | import { WarpcastPowerBadge } from "../atoms/icons/WarpcastPowerBadge"; 10 | import { formatToReadableNumber } from "../../utils/formatUtils"; 11 | import { SKELETON_PFP_URL } from "../../constants"; 12 | 13 | const StyledProfileCard = styled.div(({ theme }) => ({ 14 | display: "flex", 15 | flexDirection: "column", 16 | width: "100%", 17 | maxWidth: "608px", 18 | borderWidth: "1px", 19 | borderStyle: "solid", 20 | borderColor: theme.vars.palette.border, 21 | borderRadius: "15px", 22 | padding: "30px", 23 | color: theme.vars.palette.text, 24 | fontFamily: theme.typography.fonts.base, 25 | fontSize: theme.typography.fontSizes.medium, 26 | backgroundColor: theme.vars.palette.background, 27 | })); 28 | 29 | const Main = styled.div(() => ({ 30 | display: "flex", 31 | flexDirection: "column", 32 | justifyContent: "space-between", 33 | flex: 1, 34 | })); 35 | 36 | const Username = styled.div(({ theme }) => ({ 37 | color: theme.vars.palette.textMuted, 38 | })); 39 | 40 | const UsernameTitle = styled.div(({ theme }) => ({ 41 | fontSize: theme.typography.fontSizes.large, 42 | fontWeight: theme.typography.fontWeights.bold, 43 | })); 44 | 45 | const ProfileMetaCell = styled.div(({ theme }) => ({ 46 | color: theme.vars.palette.textMuted, 47 | "> strong": { 48 | color: theme.vars.palette.text, 49 | }, 50 | "& + &": { 51 | marginLeft: "15px", 52 | }, 53 | })); 54 | 55 | const Tag = styled.div(({ theme }) => ({ 56 | borderWidth: "1px", 57 | borderStyle: "solid", 58 | borderColor: theme.vars.palette.border, 59 | borderRadius: "5px", 60 | padding: "3px 6px", 61 | marginTop: "3px", 62 | marginLeft: "5px", 63 | backgroundColor: "transparent", 64 | fontSize: theme.typography.fontSizes.small, 65 | color: theme.vars.palette.textMuted, 66 | lineHeight: 1, 67 | })); 68 | 69 | export type ProfileCardProps = { 70 | fid?: number; 71 | username: string; 72 | displayName: string; 73 | avatarImgUrl: string; 74 | bio: string; 75 | followers: number; 76 | following: number; 77 | hasPowerBadge: boolean; 78 | isFollowing?: boolean; 79 | isOwnProfile?: boolean; 80 | onCast?: () => void; 81 | containerStyles?: React.CSSProperties; 82 | }; 83 | 84 | export const ProfileCard = memo( 85 | ({ 86 | fid, 87 | username, 88 | displayName, 89 | avatarImgUrl, 90 | bio, 91 | followers, 92 | following, 93 | hasPowerBadge, 94 | isFollowing, 95 | isOwnProfile, 96 | onCast, 97 | containerStyles, 98 | }: ProfileCardProps) => { 99 | const linkifiedBio = useLinkifyBio(bio); 100 | 101 | const formattedFollowingCount = useMemo( 102 | () => formatToReadableNumber(following), 103 | [following] 104 | ); 105 | 106 | const formattedFollowersCount = useMemo( 107 | () => formatToReadableNumber(followers), 108 | [followers] 109 | ); 110 | 111 | const handleEditProfile = () => { 112 | window.open("https://warpcast.com/~/settings", "_blank"); 113 | }; 114 | 115 | const customNumberStyle = { 116 | color: containerStyles?.color, 117 | }; 118 | 119 | return ( 120 | 121 | {isOwnProfile && onCast && ( 122 | 127 | @{username} 128 | Cast 129 | 130 | )} 131 | 132 | 133 | 138 | 139 |
140 | 141 | 142 | 143 | {displayName || `!${fid}`} 144 | {hasPowerBadge && ( 145 | 146 | 147 | 148 | )} 149 | 150 | 151 | @{username} 152 | {isFollowing && Follows you} 153 | 154 | 155 | 156 | {isOwnProfile && ( 157 | 158 | Edit Profile 159 | 160 | )} 161 | 162 | 163 | 164 | 165 |
{linkifiedBio}
166 |
167 | 168 | 169 | 170 | {formattedFollowingCount} Following 171 | 172 | 173 | {formattedFollowersCount} Followers 174 | 175 | 176 |
177 |
178 |
179 | ); 180 | } 181 | ); -------------------------------------------------------------------------------- /src/components/molecules/UserDropdown.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { styled } from '@pigment-css/react'; 3 | 4 | interface User { 5 | fid: number; 6 | username: string; 7 | display_name: string; 8 | pfp_url: string; 9 | } 10 | 11 | interface UserDropdownProps { 12 | users: User[]; 13 | onSelect: (user: User) => void; 14 | customStyles?: { 15 | dropdown?: React.CSSProperties; 16 | listItem?: React.CSSProperties; 17 | avatar?: React.CSSProperties; 18 | userInfo?: React.CSSProperties; 19 | }; 20 | } 21 | 22 | 23 | const DropdownList = styled.ul(() => ({ 24 | position: 'absolute', 25 | top: '100%', 26 | left: 0, 27 | width: '100%', 28 | maxHeight: '200px', 29 | overflowY: 'auto', 30 | border: '1px solid #ccc', 31 | borderTop: 'none', 32 | listStyle: 'none', 33 | padding: 0, 34 | margin: 0, 35 | backgroundColor: 'white', 36 | zIndex: 1000, 37 | })); 38 | 39 | const ListItem = styled.li(() => ({ 40 | padding: '8px', 41 | cursor: 'pointer', 42 | display: 'flex', 43 | alignItems: 'center', 44 | '&:hover': { 45 | backgroundColor: '#f0f0f0', 46 | }, 47 | })); 48 | 49 | const Avatar = styled.img(() => ({ 50 | width: '30px', 51 | height: '30px', 52 | borderRadius: '50%', 53 | marginRight: '10px', 54 | })); 55 | 56 | const UserInfo = styled.div(() => ({ 57 | display: 'flex', 58 | flexDirection: 'column', 59 | })); 60 | 61 | const DisplayName = styled.div(() => ({ 62 | fontWeight: 'bold', 63 | })); 64 | 65 | const Username = styled.div(() => ({ 66 | fontSize: '0.8em', 67 | color: '#666', 68 | })); 69 | 70 | const UserDropdown: React.FC = ({ 71 | users, 72 | onSelect, 73 | customStyles = {} 74 | }) => { 75 | return ( 76 | 77 | {users.map((user) => ( 78 | onSelect(user)}> 79 | 80 | 81 | {user.display_name} 82 | @{user.username} 83 | 84 | 85 | ))} 86 | 87 | ); 88 | }; 89 | 90 | export default UserDropdown; -------------------------------------------------------------------------------- /src/components/organisms/NeynarAuthButton/index.tsx: -------------------------------------------------------------------------------- 1 | // src/components/organisms/NeynarAuthButton/NeynarAuthButton.tsx 2 | import React, { useCallback, useEffect, useState, useRef } from "react"; 3 | import { styled } from "@pigment-css/react"; 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 "../../atoms/icons/FarcasterIcon"; 11 | import PlanetBlackIcon from "../../atoms/icons/PlanetBlackIcon"; 12 | import { WarpcastIcon } from "../../atoms/icons/WarpcastIcon"; 13 | interface ButtonProps extends React.ButtonHTMLAttributes { 14 | label?: string; 15 | icon?: React.ReactNode; 16 | variant?: SIWN_variant; 17 | customLogoUrl?: string; 18 | modalStyle?: React.CSSProperties; 19 | modalButtonStyle?: React.CSSProperties; 20 | } 21 | 22 | const Img = styled.img({ 23 | width: "20px", 24 | height: "20px", 25 | borderRadius: "50%", 26 | }); 27 | 28 | const Button = styled.button((props) => ({ 29 | backgroundColor: "#ffffff", 30 | border: "none", 31 | color: "#000000", 32 | padding: "15px", 33 | fontSize: "15px", 34 | fontWeight: "600", 35 | lineHeight: "18.9px", 36 | borderRadius: "100px", 37 | display: "flex", 38 | alignItems: "center", 39 | justifyContent: "center", 40 | cursor: "pointer", 41 | textDecoration: "none", 42 | boxShadow: "0 2px 4px rgba(0, 0, 0, 0.2)", 43 | transition: "background-color 0.3s", 44 | })); 45 | 46 | const Modal = styled.div((props) => ({ 47 | position: "fixed", 48 | top: "50%", 49 | left: "50%", 50 | transform: "translate(-50%, -50%)", 51 | width: "300px", 52 | padding: "20px", 53 | display: "flex", 54 | flexDirection: "column", 55 | justifyContent: "space-around", 56 | rowGap: "20px", 57 | alignItems: "center", 58 | backgroundColor: "#fff", 59 | borderRadius: "15px", 60 | boxShadow: "0 4px 8px rgba(0, 0, 0, 0.1)", 61 | zIndex: "1000", 62 | fontFamily: props.theme.typography.fonts.base, 63 | fontSize: props.theme.typography.fontSizes.medium, 64 | "> img": { 65 | width: "80px", 66 | height: "80px", 67 | borderRadius: "50%", 68 | }, 69 | "> span": { 70 | color: "#000", 71 | fontWeight: "bold", 72 | }, 73 | })); 74 | 75 | const ModalButton = styled.button({ 76 | width: "100%", 77 | padding: "10px 0", 78 | backgroundColor: "#ffffff", 79 | color: "#000000", 80 | border: "1px solid #e0e0e0", 81 | borderRadius: "8px", 82 | fontWeight: "bold", 83 | cursor: "pointer", 84 | transition: "background-color 0.2s", 85 | "&:hover": { 86 | boxShadow: "0 2px 3px rgba(0, 0, 0, 0.1)", 87 | }, 88 | }); 89 | 90 | const getLabel = (variant: SIWN_variant, label: string | undefined) => { 91 | if (label) { 92 | return label; 93 | } 94 | 95 | switch (variant) { 96 | case SIWN_variant.FARCASTER: 97 | return "Sign in with Farcaster"; 98 | case SIWN_variant.NEYNAR: 99 | return "Sign in with Neynar"; 100 | case SIWN_variant.WARPCAST: 101 | return "Sign in with Warpcast"; 102 | default: 103 | return "Sign in with Neynar"; 104 | } 105 | }; 106 | 107 | const getIcon = ( 108 | variant: SIWN_variant, 109 | icon: React.ReactNode, 110 | customLogoUrl: string | undefined 111 | ) => { 112 | if (icon) { 113 | return icon; 114 | } 115 | 116 | if (customLogoUrl) { 117 | return Custom logo; 118 | } 119 | 120 | switch (variant) { 121 | case SIWN_variant.FARCASTER: 122 | return ; 123 | case SIWN_variant.NEYNAR: 124 | return ; 125 | case SIWN_variant.WARPCAST: 126 | return ; 127 | default: 128 | return ; 129 | } 130 | }; 131 | 132 | export const NeynarAuthButton: React.FC = ({ 133 | children, 134 | label, 135 | variant = SIWN_variant.NEYNAR, 136 | icon, 137 | customLogoUrl, 138 | modalStyle = {}, 139 | modalButtonStyle = {}, 140 | ...rest 141 | }) => { 142 | const { client_id, user, isAuthenticated } = useNeynarContext(); 143 | const { setIsAuthenticated, setUser, onAuthSuccess, onSignout } = useAuth(); 144 | const [_, setNeynarAuthenticatedUser, removeNeynarAuthenticatedUser] = 145 | useLocalStorage( 146 | LocalStorageKeys.NEYNAR_AUTHENTICATED_USER 147 | ); 148 | const [showModal, setShowModal] = useState(false); 149 | 150 | const authWindowRef = useRef(null); 151 | const neynarLoginUrl = `${process.env.NEYNAR_LOGIN_URL ?? "https://app.neynar.com/login"}?client_id=${client_id}`; 152 | const authOrigin = new URL(neynarLoginUrl).origin; 153 | 154 | const modalRef = useRef(null); 155 | 156 | const handleMessage = useCallback( 157 | async (event: MessageEvent) => { 158 | if ( 159 | event.origin === authOrigin && 160 | event.data && 161 | event.data.is_authenticated 162 | ) { 163 | setIsAuthenticated(true); 164 | authWindowRef.current?.close(); 165 | window.removeEventListener("message", handleMessage); // Remove listener here 166 | const _user = { 167 | signer_uuid: event.data.signer_uuid, 168 | ...event.data.user, 169 | }; 170 | setNeynarAuthenticatedUser(_user); 171 | setUser(_user); 172 | onAuthSuccess({ user: _user }); 173 | } 174 | }, 175 | [client_id, setIsAuthenticated] 176 | ); 177 | 178 | const handleSignIn = useCallback(() => { 179 | const width = 600, 180 | height = 700; 181 | const left = window.screen.width / 2 - width / 2; 182 | const top = window.screen.height / 2 - height / 2; 183 | const windowFeatures = `width=${width},height=${height},top=${top},left=${left}`; 184 | 185 | authWindowRef.current = window.open( 186 | neynarLoginUrl, 187 | "_blank", 188 | windowFeatures 189 | ); 190 | 191 | if (!authWindowRef.current) { 192 | console.error( 193 | "Failed to open the authentication window. Please check your pop-up blocker settings." 194 | ); 195 | return; 196 | } 197 | 198 | window.addEventListener("message", handleMessage, false); 199 | }, [client_id, handleMessage]); 200 | 201 | const handleSignOut = () => { 202 | if (user) { 203 | const _user = user; 204 | removeNeynarAuthenticatedUser(); 205 | setIsAuthenticated(false); 206 | closeModal(); 207 | const { signer_uuid, ...rest } = _user; 208 | onSignout(rest); 209 | } 210 | }; 211 | 212 | const openModal = () => setShowModal(true); 213 | const closeModal = () => setShowModal(false); 214 | 215 | useEffect(() => { 216 | return () => { 217 | window.removeEventListener("message", handleMessage); // Cleanup function to remove listener 218 | }; 219 | }, [handleMessage]); 220 | 221 | const handleOutsideClick = useCallback((event: any) => { 222 | if (modalRef.current && !modalRef.current.contains(event.target)) { 223 | closeModal(); 224 | } 225 | }, []); 226 | 227 | useEffect(() => { 228 | if (showModal) { 229 | document.addEventListener("mousedown", handleOutsideClick); 230 | } else { 231 | document.removeEventListener("mousedown", handleOutsideClick); 232 | } 233 | 234 | return () => { 235 | document.removeEventListener("mousedown", handleOutsideClick); 236 | }; 237 | }, [showModal, handleOutsideClick]); 238 | 239 | return ( 240 | <> 241 | {showModal && ( 242 | 243 | {user?.username} 244 | @{user?.username} 245 | 246 | Sign out 247 | 248 | 249 | )} 250 | 268 | 269 | ); 270 | }; 271 | -------------------------------------------------------------------------------- /src/components/organisms/NeynarCastCard/hooks/useLinkifyCast.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 = /(^|\s)\/\w+/g; 7 | const mentionRegex = /@\w+(\.eth)?/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.trim()}`; 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 | type Embed = { 31 | url?: string; 32 | }; 33 | 34 | const extractUrlsFromEmbeds = (embeds: Embed[]): string[] => { 35 | return embeds 36 | .filter((embed) => embed.url) 37 | .map((embed) => embed.url!); 38 | }; 39 | 40 | export const useLinkifyCast = (text: string, embeds: Embed[]): React.ReactNode[] => { 41 | if (!text) return []; 42 | 43 | const excludedUrls = extractUrlsFromEmbeds(embeds); 44 | const elements: React.ReactNode[] = []; 45 | let lastIndex = 0; 46 | 47 | let match; 48 | while ((match = combinedRegex.exec(text)) !== null) { 49 | const matchIndex = match.index; 50 | if (lastIndex < matchIndex) { 51 | elements.push(text.slice(lastIndex, matchIndex)); 52 | } 53 | 54 | const matchedUrl = match[0].trim(); 55 | if (!excludedUrls.includes(matchedUrl)) { 56 | const url = generateUrl(matchedUrl); 57 | elements.push( 58 | 59 | {matchedUrl} 60 | 61 | ); 62 | } else { 63 | elements.push(matchedUrl); 64 | } 65 | 66 | lastIndex = combinedRegex.lastIndex; 67 | } 68 | 69 | if (lastIndex < text.length) { 70 | elements.push(text.slice(lastIndex)); 71 | } 72 | 73 | return elements; 74 | }; -------------------------------------------------------------------------------- /src/components/organisms/NeynarCastCard/hooks/useRenderEmbeds.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import Hls from 'hls.js'; 3 | import { METADATA_PROXY_URL } from "../../../../constants"; 4 | import { NeynarCastCard } from ".."; 5 | import { styled } from "@pigment-css/react"; 6 | 7 | interface OpenGraphData { 8 | ogImage: string; 9 | ogTitle: string; 10 | ogDescription: string; 11 | } 12 | 13 | interface Embed { 14 | url?: string; 15 | cast_id?: { 16 | fid: number; 17 | hash: string; 18 | }; 19 | } 20 | 21 | interface ImageWrapperProps { 22 | src: string; 23 | alt: string; 24 | style?: React.CSSProperties; 25 | } 26 | 27 | interface NativeVideoPlayerProps { 28 | url: string; 29 | } 30 | 31 | const StyledLink = styled.a(({ theme }) => ({ 32 | textDecoration: "none", 33 | color: theme.vars.palette.text, 34 | overflowWrap: "break-word", 35 | display: 'flex', 36 | alignItems: 'center', 37 | border: '1px solid grey', 38 | borderRadius: '8px', 39 | padding: '8px', 40 | gap: '8px', 41 | })); 42 | 43 | const openGraphCache = new Map(); 44 | const pendingRequests = new Map>(); 45 | const domainErrorTracker = new Map(); 46 | 47 | const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); 48 | 49 | const fetchOpenGraphData = async (url: string, retryCount = 0): Promise => { 50 | const domain = new URL(url).hostname; 51 | 52 | if (domainErrorTracker.get(domain)) { 53 | return { ogImage: '', ogTitle: '', ogDescription: '' }; 54 | } 55 | 56 | if (openGraphCache.has(url)) { 57 | return openGraphCache.get(url)!; 58 | } 59 | 60 | if (pendingRequests.has(url)) { 61 | return pendingRequests.get(url)!; 62 | } 63 | 64 | const fetchPromise = (async () => { 65 | try { 66 | await delay(100); 67 | // note: `METADATA_PROXY_URL` is a public(non-Neynar) proxy to avoid CORS issues when retrieving opengraph metadata. Feel free to substitute with your own proxy if you'd rather. 68 | const response = await fetch(`${METADATA_PROXY_URL}?url=${encodeURIComponent(url)}`, { method: 'GET' }); 69 | 70 | if (!response.ok) { 71 | if (response.status === 429 && retryCount < 5) { 72 | const backoff = Math.pow(2, retryCount) * 1000; 73 | await delay(backoff); 74 | return fetchOpenGraphData(url, retryCount + 1); 75 | } 76 | domainErrorTracker.set(domain, true); 77 | throw new Error(`Failed to fetch Open Graph data: ${response.statusText}`); 78 | } 79 | 80 | const data = await response.json(); 81 | const parser = new DOMParser(); 82 | const doc = parser.parseFromString(data.contents, 'text/html'); 83 | const ogImageMeta = doc.querySelector('meta[property="og:image"]'); 84 | const ogTitleMeta = doc.querySelector('meta[property="og:title"]'); 85 | const ogDescriptionMeta = doc.querySelector('meta[property="og:description"]'); 86 | const titleTag = doc.querySelector('title'); 87 | 88 | const ogImage = ogImageMeta ? ogImageMeta.getAttribute('content') || '' : ''; 89 | const ogTitle = ogTitleMeta ? ogTitleMeta.getAttribute('content') || '' : (titleTag ? titleTag.innerText : ''); 90 | const ogDescription = ogDescriptionMeta ? ogDescriptionMeta.getAttribute('content') || '' : ''; 91 | 92 | const openGraphData: OpenGraphData = { ogImage, ogTitle, ogDescription }; 93 | openGraphCache.set(url, openGraphData); 94 | return openGraphData; 95 | } catch (error) { 96 | console.error("Error fetching Open Graph data", error); 97 | return { ogImage: '', ogTitle: '', ogDescription: '' }; 98 | } finally { 99 | pendingRequests.delete(url); 100 | } 101 | })(); 102 | 103 | pendingRequests.set(url, fetchPromise); 104 | return fetchPromise; 105 | }; 106 | 107 | const requestQueue: (() => Promise)[] = []; 108 | let activeRequests = 0; 109 | const MAX_CONCURRENT_REQUESTS = 5; 110 | 111 | const enqueueRequest = (requestFn: () => Promise) => { 112 | requestQueue.push(requestFn); 113 | processQueue(); 114 | }; 115 | 116 | const processQueue = async () => { 117 | if (activeRequests >= MAX_CONCURRENT_REQUESTS || requestQueue.length === 0) { 118 | return; 119 | } 120 | 121 | activeRequests++; 122 | const nextRequest = requestQueue.shift(); 123 | if (nextRequest) { 124 | await nextRequest(); 125 | } 126 | activeRequests--; 127 | 128 | processQueue(); 129 | }; 130 | 131 | const ImageWrapper: React.FC = ({ src, alt, style }) => ( 132 | {alt} 148 | ); 149 | 150 | const NativeVideoPlayer: React.FC = ({ url }) => { 151 | const videoRef = React.useRef(null); 152 | 153 | React.useEffect(() => { 154 | if (videoRef.current) { 155 | if (Hls.isSupported() && url.endsWith('.m3u8')) { 156 | const hls = new Hls(); 157 | hls.loadSource(url); 158 | hls.attachMedia(videoRef.current); 159 | hls.on(Hls.Events.MANIFEST_PARSED, () => { 160 | videoRef.current!.play(); 161 | }); 162 | } else { 163 | videoRef.current.src = url; 164 | videoRef.current.addEventListener('loadedmetadata', () => { 165 | videoRef.current!.play(); 166 | }); 167 | } 168 | } 169 | }, [url]); 170 | 171 | return ( 172 |