Feat: Edit and update your favorite "Pinned Casts" (#850)

* load frames from a cast and display

* add more example

* fetcn and display cast from neymar APi

* tidy up

* tidy up

* advanced embed casts

* design cleanup

* image sanitazation

* cleanup
This commit is contained in:
Léo Galley
2024-08-16 13:54:51 -04:00
committed by GitHub
parent ff33bf0f89
commit 3272d3e43d
15 changed files with 484 additions and 4 deletions

View File

@@ -1,5 +1,6 @@
'use client';
import '@rainbow-me/rainbowkit/styles.css';
import '@coinbase/onchainkit/styles.css';
import {
Provider as CookieManagerProvider,

View File

@@ -22,6 +22,30 @@ export function middleware(req: NextRequest) {
return NextResponse.redirect(url);
}
// Open img and media csp on username profile to support frames
if (url.pathname.startsWith('/name/')) {
const nonce = Buffer.from(crypto.randomUUID()).toString('base64');
// Open image src
const cspHeader = `
img-src 'self' https: data:;
media-src 'self' https: data: blob:;
`;
const contentSecurityPolicyHeaderValue = cspHeader.replace(/\s{2,}/g, ' ').trim();
const requestHeaders = new Headers(req.headers);
requestHeaders.set('x-nonce', nonce);
requestHeaders.set('Content-Security-Policy', contentSecurityPolicyHeaderValue);
const response = NextResponse.next({
request: {
headers: requestHeaders,
},
});
response.headers.set('Content-Security-Policy', contentSecurityPolicyHeaderValue);
return response;
}
if (url.pathname === '/guides/run-a-base-goerli-node') {
url.host = 'docs.base.org';
url.pathname = '/tutorials/run-a-base-node';

View File

@@ -108,6 +108,7 @@ const contentSecurityPolicy = {
'https://browser-intake-datadoghq.com', // datadog
'https://*.datadoghq.com', //datadog
'https://translate.googleapis.com', // Let user translate our website
'https://sdk-api.neynar.com/', // Neymar API
],
'frame-ancestors': ["'self'", baseXYZDomains],
'form-action': ["'self'", baseXYZDomains],

View File

@@ -30,6 +30,7 @@
"classnames": "^2.5.1",
"ethers": "5.7.2",
"framer-motion": "^8.5.5",
"hls.js": "^1.5.14",
"is-ipfs": "^8.0.4",
"jose": "^5.4.1",
"jsonwebtoken": "^9.0.2",

View File

@@ -0,0 +1,98 @@
import Fieldset from 'apps/web/src/components/Fieldset';
import Hint, { HintVariants } from 'apps/web/src/components/Hint';
import Input from 'apps/web/src/components/Input';
import Label from 'apps/web/src/components/Label';
import {
UsernameTextRecordKeys,
textRecordsKeysForDisplay,
textRecordsKeysPlaceholderForDisplay,
} from 'apps/web/src/utils/usernames';
import { ChangeEvent, ReactNode, useCallback, useEffect, useId, useState } from 'react';
type UsernameCastInputProps = {
value?: string;
onChange: (value: string, index: number) => void;
index: number;
disabled: boolean;
};
const warpcastUrlRegex = /^https:\/\/warpcast\.com\/([a-zA-Z0-9._-]+)\/0x[0-9a-fA-F]+$/;
function UsernameCastInput({ value = '', onChange, index, disabled }: UsernameCastInputProps) {
const onChangeCastUrl = useCallback(
(event: ChangeEvent<HTMLInputElement>) => {
onChange(event.target.value, index);
},
[index, onChange],
);
const validCast = !value || value.match(warpcastUrlRegex);
return (
<Fieldset>
<Input
key={`cast_frame_${value}`}
placeholder={textRecordsKeysPlaceholderForDisplay[UsernameTextRecordKeys.Casts]}
onChange={onChangeCastUrl}
disabled={disabled}
className="rounded-md border border-gray-40/20 p-2 text-black"
value={value}
type="url"
/>
{!validCast && <Hint variant={HintVariants.Error}>Must be a Warpcast URL</Hint>}
</Fieldset>
);
}
export type UsernameCastsFieldProps = {
labelChildren?: ReactNode;
onChange: (key: UsernameTextRecordKeys, value: string) => void;
value: string;
disabled?: boolean;
};
const MAX_CASTS = 4;
export default function UsernameCastsField({
labelChildren = textRecordsKeysForDisplay[UsernameTextRecordKeys.Casts],
onChange,
value,
disabled = false,
}: UsernameCastsFieldProps) {
const [casts, setCastsUrls] = useState<string[]>(value.split(',').filter((keyword) => !!keyword));
useEffect(() => {
onChange(UsernameTextRecordKeys.Casts, casts.join(','));
}, [casts, onChange]);
useEffect(() => {
setCastsUrls(value.split(',').filter((keyword) => !!keyword));
}, [value]);
const UsernameCastsFieldId = useId();
const onChangeCast = useCallback(
(inputValue: string, index: number) => {
const newCasts = [...casts];
newCasts[index] = inputValue;
setCastsUrls(newCasts);
},
[casts],
);
return (
<Fieldset className="w-full">
{labelChildren && <Label htmlFor={UsernameCastsFieldId}>{labelChildren}</Label>}
{Array.from(Array(MAX_CASTS).keys()).map((index) => (
<UsernameCastInput
key={`cast_frame_${index}`}
onChange={onChangeCast}
disabled={disabled}
index={index}
value={casts[index]}
/>
))}
</Fieldset>
);
}

View File

@@ -0,0 +1,29 @@
import { useUsernameProfile } from 'apps/web/src/components/Basenames/UsernameProfileContext';
import UsernameProfileSectionTitle from 'apps/web/src/components/Basenames/UsernameProfileSectionTitle';
import NeymarCast from 'apps/web/src/components/NeymarCast';
import useReadBaseEnsTextRecords from 'apps/web/src/hooks/useReadBaseEnsTextRecords';
export default function UsernameProfileCasts() {
const { profileUsername, profileAddress } = useUsernameProfile();
const { existingTextRecords } = useReadBaseEnsTextRecords({
address: profileAddress,
username: profileUsername,
});
const casts = existingTextRecords.casts.split(',').filter((cast) => !!cast);
if (casts.length === 0) return null;
return (
<section>
<UsernameProfileSectionTitle title="Pinned casts" />
<ul className="mt-6 grid grid-cols-1 gap-8 md:grid-cols-2">
{casts.map((cast) => (
<li key={cast}>
<NeymarCast identifier={cast} type="url" />
</li>
))}
</ul>
</section>
);
}

View File

@@ -1,10 +1,14 @@
import UsernameProfileSectionBadges from 'apps/web/src/components/Basenames/UsernameProfileSectionBadges';
import UsernameProfileSectionExplore from 'apps/web/src/components/Basenames/UsernameProfileSectionExplore';
import BadgeContextProvider from 'apps/web/src/components/Basenames/UsernameProfileSectionBadges/BadgeContext';
import UsernameProfileCasts from 'apps/web/src/components/Basenames/UsernameProfileCasts';
export default function UsernameProfileContent() {
return (
<div className="flex flex-col gap-8 rounded-2xl border border-[#EBEBEB] p-4 shadow-lg md:gap-12 md:p-12">
<UsernameProfileCasts />
<UsernameProfileSectionBadges />
<BadgeContextProvider>
<UsernameProfileSectionBadges />
</BadgeContextProvider>

View File

@@ -15,6 +15,7 @@ import {
textRecordsSocialFieldsEnabled,
UsernameTextRecordKeys,
} from 'apps/web/src/utils/usernames';
import UsernameCastsField from 'apps/web/src/components/Basenames/UsernameCastsField';
const settingTabClass = classNames(
'flex flex-col justify-between gap-8 text-gray/60 md:items-center p-4 md:p-8',
@@ -89,6 +90,14 @@ export default function UsernameProfileSettingsManageProfile() {
disabled={writeTextRecordsIsPending}
/>
</div>
<div className="mb-2 w-full">
<UsernameCastsField
onChange={onChangeTextRecord}
value={updatedTextRecords[UsernameTextRecordKeys.Casts]}
disabled={writeTextRecordsIsPending}
/>
</div>
</section>
{/* Settings UI: The save section */}
<div className="md:p-center flex items-center justify-between gap-4 border-t border-[#EBEBEB] p-4 md:p-8">

View File

@@ -0,0 +1,216 @@
import ImageWithLoading from 'apps/web/src/components/ImageWithLoading';
import NeymarFrame from 'apps/web/src/components/NeymarFrame';
import { fetchCast, NeymarCastData } from 'apps/web/src/utils/frames';
import Link from 'next/link';
import { MouseEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import Hls from 'hls.js';
import { useErrors } from 'apps/web/contexts/Errors';
import classNames from 'classnames';
// Image embed
const isImageUrl = (url: string): boolean => {
try {
// This will trigger an error if it's not a valid URL
const parsedUrl = new URL(url);
// Ends with an image format
return /\.(jpeg|jpg|gif|png|webp|bmp|svg)$/.test(parsedUrl.pathname);
} catch (error) {
return false;
}
};
// Video embed
const isVideoUrl = (url: string): boolean => url.endsWith('.m3u8') || url.endsWith('.mp4');
type NativeVideoPlayerProps = {
url: string;
};
function NativeVideoPlayer({ url }: NativeVideoPlayerProps) {
const videoRef = useRef<HTMLVideoElement | null>(null);
useEffect(() => {
if (videoRef.current) {
if (Hls.isSupported() && url.endsWith('.m3u8')) {
const hls = new Hls();
hls.loadSource(url);
hls.attachMedia(videoRef.current);
} else {
videoRef.current.src = url;
}
}
}, [url]);
return <video ref={videoRef} controls muted className="overflow-hidden rounded-2xl" />;
}
// Links in text
const WARPCAST_DOMAIN = 'https://warpcast.com';
const channelRegex = /(^|\s)\/\w+/g;
const mentionRegex = /@\w+(\.eth)?/g;
const urlRegex = /((https?:\/\/)?([a-zA-Z0-9.-]+\.[a-zA-Z]{2,})(\/[^\s]*)?)/g;
const combinedRegex = new RegExp(
`(${channelRegex.source})|(${mentionRegex.source})|(${urlRegex.source})`,
'g',
);
const generateUrl = (match: string): string => {
if (channelRegex.test(match)) {
return `${WARPCAST_DOMAIN}/~/channel${match.trim()}`;
} else if (mentionRegex.test(match)) {
return `${WARPCAST_DOMAIN}/${match.substring(1)}`;
} else if (urlRegex.test(match)) {
return match.startsWith('http') ? match : `http://${match}`;
}
return '';
};
function ParagraphWithLinks({ text }: { text: string }) {
const textWithLinks = useMemo(() => {
let match;
let lastIndex = 0;
const results: React.ReactNode[] = [];
while ((match = combinedRegex.exec(text)) !== null) {
const matchIndex = match.index;
if (lastIndex < matchIndex) {
const justText = text.slice(lastIndex, matchIndex);
results.push(justText);
}
const matchedUrl = match[0].trim();
const url = generateUrl(matchedUrl);
results.push(
<Link key={matchIndex} href={url} target="_blank" className="break-words text-blue-500">
{matchedUrl}
</Link>,
);
lastIndex = combinedRegex.lastIndex;
}
if (lastIndex < text.length) {
const justText = text.slice(lastIndex);
results.push(justText);
}
return results;
}, [text]);
return textWithLinks;
}
export default function NeymarCast({
identifier,
type,
}: {
identifier: string;
type: 'url' | 'hash';
}) {
const [data, setData] = useState<NeymarCastData['cast']>();
const { logError } = useErrors();
useEffect(() => {
fetchCast({ type, identifier })
.then((result) => {
if (result) setData(result);
})
.catch((error) => {
logError(error, 'Failed to load Cast');
});
}, [identifier, logError, type]);
const onClickCast = useCallback(
(event: MouseEvent<HTMLButtonElement>) => {
const target = event.target as HTMLElement;
const isLink = target.tagName === 'a';
const parentIsLink = target.closest('a');
if (isLink || parentIsLink) return;
window.open(identifier, '_blank');
},
[identifier],
);
if (!data) return null;
const frames = data.frames ?? [];
const framesUrls = frames.map((frame) => frame.frames_url);
const embeds = data.embeds ?? [];
// Frames are both in .frames and .embed
const filteredEmbeds = embeds.filter((embed) => !framesUrls.includes(embed.url));
const { hash, text, author, parent_url: parentUrl } = data;
const textParagraph = text.split('\n\n');
const castWrapperClasses = classNames(
'flex cursor-pointer flex-col gap-4 text-left',
'max-h-[30rem] overflow-hidden rounded-3xl border border-gray-40/20 p-8 relative',
'hover:border-blue-500 transition-all',
"after:content-[''] after:absolute after:w-full after:h-[2rem] after:bottom-0 after:left-0",
'after:bg-gradient-to-b after:from-transparent after:to-white',
);
return (
<button className={castWrapperClasses} onClick={onClickCast} type="button">
{author && (
<Link href={parentUrl} target="_blank">
<header className="flex items-center gap-4">
{author.pfp_url && (
<ImageWithLoading
src={author.pfp_url}
wrapperClassName="rounded-full h-[3rem] max-h-[3rem] min-h-[3rem] w-[3rem] min-w-[3rem] max-w-[3rem]"
imageClassName="object-cover min-h-full min-w-full"
alt={`${author.display_name} Profile picture`}
width={48}
height={48}
/>
)}
<div>
<strong className="block">{author.display_name}</strong>
<span className="text-gray-40">@{author.username}</span>
</div>
</header>
</Link>
)}
{textParagraph.length > 0 && (
<ul className="flex flex-col gap-2">
{textParagraph.map((paragraph, index) => (
// It's fine to disable index warning here since the order will never change (static cast)
// eslint-disable-next-line react/no-array-index-key
<li key={`${paragraph}_${index}`}>
<ParagraphWithLinks text={paragraph} />
</li>
))}
</ul>
)}
{filteredEmbeds.length > 0 && (
<ul>
{filteredEmbeds.map((embed, index) => (
<li key={embed.url}>
{embed.url && isImageUrl(embed.url) && (
<ImageWithLoading
src={`${embed.url}_${index}`}
alt="image"
wrapperClassName="rounded-3xl overflow-hidden"
/>
)}
{embed.url && isVideoUrl(embed.url) && <NativeVideoPlayer url={embed.url} />}
</li>
))}
</ul>
)}
{frames.length > 0 && (
<ul className="flex flex-col gap-2">
{frames.map((frame) => (
<li key={frame.title}>
<NeymarFrame frame={frame} hash={hash} />
</li>
))}
</ul>
)}
</button>
);
}

View File

@@ -0,0 +1,16 @@
import ImageWithLoading from 'apps/web/src/components/ImageWithLoading';
import { NeynarFrame } from 'apps/web/src/utils/frames';
// Frame displayed from Neymar API data
// No buttons or interactions for now, just a link to the frame source
export default function NeymarFrame({ frame }: { hash: string; frame: NeynarFrame }) {
return (
<div className="overflow-hidden rounded-3xl border border-gray-40/20">
{frame.frames_url && (
<a href={frame.frames_url} target="_blank" rel="noopener noreferrer">
<ImageWithLoading src={frame.image} alt={`Frame image for ${frame.frames_url}`} />
</a>
)}
</div>
);
}

View File

@@ -35,6 +35,7 @@ export default function useReadBaseEnsTextRecords({
[UsernameTextRecordKeys.Email]: '',
[UsernameTextRecordKeys.Phone]: '',
[UsernameTextRecordKeys.Avatar]: '',
[UsernameTextRecordKeys.Casts]: '',
};
}, []);

View File

@@ -56,9 +56,9 @@ export default function useWriteBaseEnsTextRecords({
const updateTextRecords = useCallback(
(key: UsernameTextRecordKeys, value: string) => {
setUpdatedTextRecords((previousTextRecords) => ({
...previousTextRecords,
[key]: value,
}));
...previousTextRecords,
[key]: value,
}));
},
[setUpdatedTextRecords],
);

View File

@@ -0,0 +1,66 @@
import { encodeUrlQueryParams } from 'apps/web/src/utils/urls';
// TODO: There's way more that neymar returns but we only need this for now
export type NeymarButton = {
action_type: string;
index: number;
post_url: string;
title: string;
target?: string;
};
export type NeynarFrame = {
buttons: NeymarButton[];
frames_url: string;
image: string;
image_aspect_ratio: string;
title: string;
post_url?: string;
version?: string;
};
export type NeynarEmbed = {
// metadata: {};
url: string;
};
export type NeymarEmbedCast = {
cast_id: { fid: number; hash: string };
};
export type NeymarCastData = {
cast: {
frames: NeynarFrame[];
embeds: NeynarEmbed[];
hash: string;
author: {
display_name: string;
pfp_url: string;
username: string;
};
text: string;
parent_url: string;
};
};
export async function fetchCast({
identifier,
type,
}: {
identifier: string;
type: 'url' | 'hash';
}) {
const url = `https://api.neynar.com/v2/farcaster/cast?${encodeUrlQueryParams({
identifier,
type,
})}`;
const options = {
method: 'GET',
headers: { accept: 'application/json', api_key: process.env.NEXT_PUBLIC_NEYNAR_API_KEY },
};
try {
const response = await fetch(url, options);
const data = (await response.json()) as NeymarCastData;
return data.cast;
} catch (error) {}
}

View File

@@ -48,11 +48,13 @@ export const USERNAME_DESCRIPTION_MAX_LENGTH = 200;
// DANGER: Changing this post-mainnet launch means the stored data won't be accessible via the updated key
export enum UsernameTextRecordKeys {
// Defaults
Description = 'description',
Keywords = 'keywords',
Url = 'url',
Email = 'email',
Phone = 'phone',
Avatar = 'avatar',
// Socials
Github = 'com.github',
@@ -62,7 +64,8 @@ export enum UsernameTextRecordKeys {
Telegram = 'org.telegram',
Discord = 'com.discord',
Avatar = 'avatar',
// Basename specifics
Casts = 'casts',
}
// The social enabled for the current registration / profile pages
@@ -149,6 +152,7 @@ export const textRecordsKeysEnabled = [
UsernameTextRecordKeys.Telegram,
UsernameTextRecordKeys.Discord,
UsernameTextRecordKeys.Avatar,
UsernameTextRecordKeys.Casts,
];
export const textRecordsKeysForDisplay = {
@@ -164,6 +168,7 @@ export const textRecordsKeysForDisplay = {
[UsernameTextRecordKeys.Telegram]: 'Telegram',
[UsernameTextRecordKeys.Discord]: 'Discord',
[UsernameTextRecordKeys.Avatar]: 'Avatar',
[UsernameTextRecordKeys.Casts]: 'Pinned Casts',
};
export const textRecordsKeysPlaceholderForDisplay = {
@@ -179,6 +184,7 @@ export const textRecordsKeysPlaceholderForDisplay = {
[UsernameTextRecordKeys.Telegram]: 'Username',
[UsernameTextRecordKeys.Discord]: 'Username',
[UsernameTextRecordKeys.Avatar]: 'Avatar',
[UsernameTextRecordKeys.Casts]: 'https://warpcast.com/...',
};
export const textRecordsEngineersKeywords = [

View File

@@ -380,6 +380,7 @@ __metadata:
eslint-config-next: ^13.1.6
ethers: 5.7.2
framer-motion: ^8.5.5
hls.js: ^1.5.14
is-ipfs: ^8.0.4
jose: ^5.4.1
jsonwebtoken: ^9.0.2
@@ -15477,6 +15478,13 @@ __metadata:
languageName: node
linkType: hard
"hls.js@npm:^1.5.14":
version: 1.5.14
resolution: "hls.js@npm:1.5.14"
checksum: 7e8385dd188cc2fbf089d3daed12a4b0905f1468be1b1598ddba07edc9d398f9639a054b8f2a73c2d09a177eecc6559c0030ee051611f109e23559004d953d3e
languageName: node
linkType: hard
"hmac-drbg@npm:^1.0.1":
version: 1.0.1
resolution: "hmac-drbg@npm:1.0.1"