diff --git a/README.md b/README.md index 86cdbe3..7401100 100644 --- a/README.md +++ b/README.md @@ -98,7 +98,18 @@ Params: - `type` ('url' | 'hash'): The type of identifier used for the cast. - `identifier` (string): The identifier (either URL or hash) for the cast. - `viewerFid?` (number): The FID of the viewer. Default: undefined. -- `allowReactions?` (boolean, default = true): Whether to allow reactions on the cast, and when this is true the component default to using Neynar reactions +- `allowReactions?` (boolean, default = false): Whether to allow reactions on the cast +- `renderEmbeds`(boolean, default = true): Whether to allow rendering of cast embeds +- `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`) +- `onLikeBtnPress`(() => boolean) A handler to add functionality when the like button is pressed. A response of `true` indicates the like action is successful +- `onRecastBtnPress`(() => boolean) A handler to add functionality when the recast button is pressed. A response of `true` indicates the recast action is successful +- `onCommentBtnPress`(() => boolean) A handler to add functionality when the comment button is pressed. A response of `true` indicates the comment action is successful +- `onFrameBtnPress?: ( + btnIndex: number, + localFrame: NeynarFrame, + setLocalFrame: React.Dispatch>, + inputValue?: string + ) => Promise;`: A handler to add functionality when a frame button is pressed. - `customStyles?` (CSSProperties): Custom styles for the cast card. Default: {} Usage: @@ -169,7 +180,12 @@ This component displays a specific frame on Farcaster. Params: - `url` (string): The URL to fetch the frame data from. -- `onFrameBtnPress?` (function): A callback function triggered when a button in the frame is pressed. Default: The SDK handles POST actions through the Neynar API. +- `onFrameBtnPress: ( + btnIndex: number, + localFrame: NeynarFrame, + setLocalFrame: React.Dispatch>, + inputValue?: string + ) => Promise;`: A handler to add functionality when a frame button is pressed. - `initialFrame?` (NeynarFrame): The initial frame data to display. Default: undefined. Usage: diff --git a/package.json b/package.json index 875941f..e4fc946 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@neynar/react", - "version": "0.8.3", + "version": "0.9.0", "description": "Farcaster frontend component library powered by Neynar", "main": "dist/bundle.cjs.js", "module": "dist/bundle.es.js", diff --git a/src/components/molecules/CastCard.tsx b/src/components/molecules/CastCard.tsx index df1cfd1..b65ce7e 100644 --- a/src/components/molecules/CastCard.tsx +++ b/src/components/molecules/CastCard.tsx @@ -8,17 +8,7 @@ import { useRenderEmbeds } from "../organisms/NeynarCastCard/hooks/useRenderEmbe import Reactions from "../atoms/Reactions"; import { ShareToClipboardIcon } from "../atoms/icons/ShareToClipboardIcon"; import { SKELETON_PFP_URL } from "../../constants"; -import { NeynarFrameCard, NeynarFrame as NeynarFrameCardType } from "../organisms/NeynarFrameCard"; - -type NeynarFrame = { - image: string; - frames_url: string; - post_url?: string; - buttons: { - index: number; - title: string; - }[]; -}; +import { NeynarFrameCard, type NeynarFrame } from "../organisms/NeynarFrameCard"; const StyledCastCard = styled.div(({ theme }) => ({ display: "flex", @@ -156,9 +146,17 @@ export type CastCardProps = { isOwnProfile?: boolean; isEmbed?: boolean; allowReactions: boolean; - onComment?: () => void; - onRecast?: () => void; - onLike?: (newVal: boolean) => void; + renderEmbeds: boolean; + renderFrames: boolean; + onLikeBtnPress?: () => boolean; + onRecastBtnPress?: () => boolean; + onCommentBtnPress?: () => void; + onFrameBtnPress?: ( + btnIndex: number, + localFrame: NeynarFrame, + setLocalFrame: React.Dispatch>, + inputValue?: string + ) => Promise; direct_replies?: CastCardProps[]; customStyles?: React.CSSProperties; }; @@ -179,9 +177,12 @@ export const CastCard = React.memo( hasPowerBadge, isEmbed = true, allowReactions, - onComment, - onRecast, - onLike, + renderEmbeds, + renderFrames, + onLikeBtnPress, + onRecastBtnPress, + onCommentBtnPress, + onFrameBtnPress, direct_replies, customStyles }: CastCardProps) => { @@ -202,12 +203,14 @@ export const CastCard = React.memo( }, [reactions.likes, viewerFid]); const handleLike = useCallback((newVal: boolean) => { - setLikesCount(prev => newVal ? prev + 1 : prev - 1); - setIsLiked(newVal); - if (onLike) { - onLike(newVal); + if (onLikeBtnPress) { + const likeBtnPressResp = onLikeBtnPress(); + if(likeBtnPressResp){ + setLikesCount(prev => newVal ? prev + 1 : prev - 1); + setIsLiked(newVal); + } } - }, [onLike]); + }, [onLikeBtnPress]); const renderedEmbeds = useRenderEmbeds(filteredEmbeds, allowReactions, viewerFid); @@ -242,7 +245,7 @@ export const CastCard = React.memo( {linkifiedText} - {filteredEmbeds && filteredEmbeds.length > 0 && ( + {renderEmbeds && filteredEmbeds && filteredEmbeds.length > 0 ? ( {renderedEmbeds.map((embed, index) => (
@@ -250,23 +253,28 @@ export const CastCard = React.memo(
))}
- )} - {frames && frames.length > 0 && ( - - {frames.map((frame: any) => { - return ( - - ); - })} - - )} + ) : <>} + { + renderFrames && frames && frames.length > 0 ? ( + + {frames.map((frame: NeynarFrame) => ( + + ))} + + ) : null + } {allowReactions && ( diff --git a/src/components/molecules/FrameCard.tsx b/src/components/molecules/FrameCard.tsx index 508e0d9..ad9c0f6 100644 --- a/src/components/molecules/FrameCard.tsx +++ b/src/components/molecules/FrameCard.tsx @@ -78,9 +78,12 @@ const FlexContainer = styled.div({ const InputField = styled.input({ border: "1px solid rgba(255, 255, 255, 0.2)", borderRadius: "8px", - padding: "8px", - marginTop: "8px", - width: "100%", + padding: "2%", + display: "flex", + flexWrap: "wrap", + gap: "8px", + width: "94%", + marginLeft: "1%", fontSize: "14px", backgroundColor: "#1E1E1E", color: "white", @@ -186,7 +189,7 @@ function CastFrame({ frame, onFrameBtnPress }: { frame: NeynarFrame, onFrameBtnP case "1.91:1": return { aspectRatio: "1.91 / 1" }; default: - return {}; + return { aspectRatio: "1.91 / 1" }; } }; diff --git a/src/components/organisms/NeynarCastCard/index.tsx b/src/components/organisms/NeynarCastCard/index.tsx index 9cdc2a4..1ca6c65 100644 --- a/src/components/organisms/NeynarCastCard/index.tsx +++ b/src/components/organisms/NeynarCastCard/index.tsx @@ -3,6 +3,7 @@ import { NEYNAR_API_URL } from "../../../constants"; import { useNeynarContext } from "../../../contexts"; import { CastCard } from "../../molecules/CastCard"; import customFetch from "../../../utils/fetcher"; +import { type NeynarFrame } from "../NeynarFrameCard"; async function fetchCastByIdentifier({ type, @@ -32,6 +33,16 @@ export type NeynarCastCardProps = { viewerFid?: number; allowReactions?: boolean; renderEmbeds?: boolean; + renderFrames?: boolean; + onLikeBtnPress?: () => boolean; + onRecastBtnPress?: () => boolean; + onCommentBtnPress?: () => void; + onFrameBtnPress?: ( + btnIndex: number, + localFrame: NeynarFrame, + setLocalFrame: React.Dispatch>, + inputValue?: string + ) => Promise; customStyles?: React.CSSProperties; }; @@ -39,8 +50,13 @@ export const NeynarCastCard: React.FC = ({ type, identifier, viewerFid, - allowReactions = true, + allowReactions = false, renderEmbeds = true, + renderFrames = false, + onLikeBtnPress, + onRecastBtnPress, + onCommentBtnPress, + onFrameBtnPress, customStyles }) => { const { client_id } = useNeynarContext(); @@ -71,7 +87,10 @@ export const NeynarCastCard: React.FC = ({ } if (!castData || error) { - return
Error fetching user data
; + return
Error: could not fetch cast data
; + } + if (renderFrames && !onFrameBtnPress) { + return
Error: onFrameBtnPress must be provided when renderEmbeds is true.
; } return ( @@ -83,8 +102,10 @@ export const NeynarCastCard: React.FC = ({ hash={castData.hash} reactions={castData.reactions} replies={castData.replies.count} - embeds={renderEmbeds ? castData.embeds : []} + embeds={castData.embeds ?? []} frames={castData.frames ?? []} + renderEmbeds={renderEmbeds} + renderFrames={renderFrames} channel={castData.channel ? { id: castData.channel.id, name: castData.channel.name, @@ -95,6 +116,10 @@ export const NeynarCastCard: React.FC = ({ hasPowerBadge={castData.author.power_badge} isOwnProfile={isOwnProfile} customStyles={customStyles} + onLikeBtnPress={onLikeBtnPress} + onRecastBtnPress={onRecastBtnPress} + onCommentBtnPress={onCommentBtnPress} + onFrameBtnPress={onFrameBtnPress} /> ); }; \ No newline at end of file diff --git a/src/components/organisms/NeynarConversationList/index.tsx b/src/components/organisms/NeynarConversationList/index.tsx index 5e0a119..68fff61 100644 --- a/src/components/organisms/NeynarConversationList/index.tsx +++ b/src/components/organisms/NeynarConversationList/index.tsx @@ -56,11 +56,13 @@ function formatCast(cast: any): CastCardProps { replies: cast.replies.count, embeds: cast.embeds, frames: cast.frames, + renderEmbeds: cast.renderEmbeds, channel: cast.channel, viewerFid: 2, hasPowerBadge: cast.author.power_badge, isOwnProfile: false, allowReactions: true, + renderFrames: false, direct_replies: cast.direct_replies ? cast.direct_replies.map(formatCast) : [], }; } diff --git a/src/components/organisms/NeynarFeedList/index.tsx b/src/components/organisms/NeynarFeedList/index.tsx index 7c2553f..c87fa89 100644 --- a/src/components/organisms/NeynarFeedList/index.tsx +++ b/src/components/organisms/NeynarFeedList/index.tsx @@ -43,11 +43,13 @@ function formatCasts(casts: any[]): CastCardProps[] { replies: cast?.replies?.count ?? 0, embeds: cast?.embeds ?? [], frames: cast?.frames ?? [], + renderEmbeds: cast?.renderEmbeds ?? true, channel: cast?.channel ?? '', viewerFid: 2, hasPowerBadge: cast?.author?.power_badge ?? false, isOwnProfile: false, allowReactions: true, + renderFrames: false, }; }); } diff --git a/src/components/organisms/NeynarFrameCard/index.tsx b/src/components/organisms/NeynarFrameCard/index.tsx index 5aeccde..7591e34 100644 --- a/src/components/organisms/NeynarFrameCard/index.tsx +++ b/src/components/organisms/NeynarFrameCard/index.tsx @@ -10,9 +10,9 @@ import { LocalStorageKeys } from "../../../hooks/use-local-storage-state"; export type NeynarFrame = { version: string; - title: string; + title?: string; image: string; - image_aspect_ratio: string; + image_aspect_ratio?: string; buttons: { index: number; title: string; @@ -29,7 +29,7 @@ export type NeynarFrame = { export type NeynarFrameCardProps = { url: string; - onFrameBtnPress?: ( + onFrameBtnPress: ( btnIndex: number, localFrame: NeynarFrame, setLocalFrame: React.Dispatch>, @@ -85,55 +85,17 @@ export const NeynarFrameCard: React.FC = ({ url, onFrameBt } }, [url, showToast, initialFrame]); - const defaultFrameBtnPress = async ( - btnIndex: number, - localFrame: NeynarFrame, - inputValue?: string - ): Promise => { - if (!signerValue) { - showToast(ToastType.Error, 'Signer UUID is not available'); - throw new Error('Signer UUID is not available'); + const isValidNeynarFrame = (frame: any): frame is NeynarFrame => { + if (typeof frame !== 'object' || frame === null) return false; + const requiredFields = ['version', 'image', 'buttons', 'frames_url']; + for (const field of requiredFields) { + if (!(field in frame)) return false; } - - const button = localFrame.buttons.find(btn => btn.index === btnIndex); - const postUrl = button?.post_url; - - if ((button?.action_type === "link" || button?.action_type === "post_redirect" || button?.action_type === "mint")) { - window.open(button.action_type !== 'mint' && button?.target ? button?.target : localFrame.frames_url, '_blank'); - return localFrame; - } else { - try { - const response = await fetchWithTimeout(`${NEYNAR_API_URL}/v2/farcaster/frame/action?client_id=${client_id}`, { - method: "POST", - headers: { - "accept": "application/json", - "content-type": "application/json" - }, - body: JSON.stringify({ - "signer_uuid": signerValue, - "action": { - "button": button, - "frames_url": localFrame.frames_url, - "post_url": postUrl ? postUrl : localFrame.frames_url, - "input": { - "text": inputValue - } - } - }) - }) as Response; - if (response.ok) { - const json = await response.json() as NeynarFrame; - return json; - } else { - showToast(ToastType.Error, `HTTP error! status: ${response.status}`); - throw new Error(`HTTP error! status: ${response.status}`); - } - } catch (error) { - showToast(ToastType.Error, `An error occurred while processing the button press: ${error}`); - throw error; - } + if (!Array.isArray(frame.buttons) || frame.buttons.some((button: NeynarFrame['buttons'][0]) => typeof button.index !== 'number')) { + return false; } - } + return true; + } const handleFrameBtnPress = async ( btnIndex: number, @@ -142,12 +104,15 @@ export const NeynarFrameCard: React.FC = ({ url, onFrameBt inputValue?: string ) => { try { - const updatedFrame = await (onFrameBtnPress ? onFrameBtnPress(btnIndex, localFrame, setLocalFrame, inputValue) : defaultFrameBtnPress(btnIndex, localFrame, inputValue)); + const updatedFrame = await onFrameBtnPress(btnIndex, localFrame, setLocalFrame, inputValue); + if (!isValidNeynarFrame(updatedFrame)) { + throw new Error("Invalid frame data received"); + } setLocalFrame(updatedFrame); } catch (error) { showToast(ToastType.Error, `An error occurred while processing the button press: ${error}`); } - }; + }; if (error) { return ( @@ -172,4 +137,4 @@ function fetchWithTimeout(url: string, options: RequestInit, timeout: number = 8 setTimeout(() => reject(new Error('Request timed out')), timeout) ) ]); -} \ No newline at end of file +} diff --git a/src/components/stories/NeynarCastCard.stories.tsx b/src/components/stories/NeynarCastCard.stories.tsx index d596f51..cc4cd50 100644 --- a/src/components/stories/NeynarCastCard.stories.tsx +++ b/src/components/stories/NeynarCastCard.stories.tsx @@ -20,12 +20,18 @@ const TemplateWithCast: StoryFn = ({ identifier, viewerFid, allowReactions, + renderEmbeds, + renderFrames, + onFrameBtnPress }) => ( ); @@ -34,13 +40,17 @@ const TemplateWithCustomStyling: StoryFn = ({ identifier, viewerFid, allowReactions, - customStyles, + renderEmbeds, + renderFrames, + customStyles }) => ( ); @@ -63,6 +73,8 @@ Primary.args = { isOwnProfile: true, onCast: () => {}, allowReactions: false, + renderEmbeds: true, + renderFrames: false }; Primary.argTypes = { fid: { table: { disable: true } }, @@ -74,55 +86,76 @@ export const WithCast = TemplateWithCast.bind({}); WithCast.args = { type: 'url', identifier: "https://warpcast.com/dylsteck.eth/0xda6b1699", - allowReactions: true, + allowReactions: false, + renderEmbeds: true, + renderFrames: false }; export const CastWithQuoteCast = TemplateWithCast.bind({}); CastWithQuoteCast.args = { type: 'url', identifier: "https://warpcast.com/nonlinear.eth/0x4e09e86c", - allowReactions: true, + allowReactions: false, + renderEmbeds: true, + renderFrames: false }; export const CastWithImage = TemplateWithCast.bind({}); CastWithImage.args = { type: 'url', identifier: "https://warpcast.com/rish/0xcc752c55", - allowReactions: true, + allowReactions: false, + renderEmbeds: true, + renderFrames: false }; export const CastWithImageAndLink = TemplateWithCast.bind({}); CastWithImageAndLink.args = { type: 'url', identifier: "https://warpcast.com/giuseppe/0x1805c345", - allowReactions: true, + allowReactions: false, + renderEmbeds: true, + renderFrames: false }; export const CastWithTwoImages = TemplateWithCast.bind({}); CastWithTwoImages.args = { type: 'url', identifier: "https://warpcast.com/nicholas/0xd06c1e56", - allowReactions: true, + allowReactions: false, + renderEmbeds: true, + renderFrames: false }; export const CastWithVideo = TemplateWithCast.bind({}); CastWithVideo.args = { type: 'url', identifier: "https://warpcast.com/coinbasewallet/0xb9dee5f9", - allowReactions: true, + allowReactions: false, + renderEmbeds: true, + renderFrames: false +}; + +const emptyFunction = () => { + return; }; export const CastWithFrame = TemplateWithCast.bind({}); CastWithFrame.args = { type: 'url', identifier: "https://warpcast.com/slokh/0x57e03c32", - allowReactions: true, + allowReactions: false, + renderEmbeds: true, + renderFrames: true, + onFrameBtnPress: emptyFunction }; export const WithCustomStyling = TemplateWithCustomStyling.bind({}); WithCustomStyling.args = { type: 'url', identifier: "https://warpcast.com/dylsteck.eth/0xda6b1699", - allowReactions: true, + allowReactions: false, + renderEmbeds: true, + renderFrames: false, customStyles: { background: "black", color: "white" }, }; \ No newline at end of file diff --git a/src/components/stories/NeynarFrameCard.stories.tsx b/src/components/stories/NeynarFrameCard.stories.tsx index 19ec90e..89b744a 100644 --- a/src/components/stories/NeynarFrameCard.stories.tsx +++ b/src/components/stories/NeynarFrameCard.stories.tsx @@ -81,6 +81,16 @@ ParagraphFrame.args = { url: "https://paragraph.xyz/@blog/introducing-smart-wallets" }; +export const PonderPollFrame = TemplateWithInteractions.bind({}); +PonderPollFrame.args = { + url: "https://frame.weponder.io/api/polls/10419" +}; + +export const PonderQuestionFrame = TemplateWithInteractions.bind({}); +PonderQuestionFrame.args = { + url: "https://frame.weponder.io/api/questions/710756bd-247f-4015-add2-1d62df8e3e91" +}; + export const ZoraFrame = TemplateWithInteractions.bind({}); ZoraFrame.args = { url: "https://zora.co/collect/base:0xcf6e80defd9be067f5adda2924b55c2186d3e930/5"